Skip to content
Snippets Groups Projects
unit.service.ts 17.5 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { Injectable } from '@angular/core';
    
    rhenck's avatar
    rhenck committed
    import { DomSanitizer } from '@angular/platform-browser';
    
    import { firstValueFrom, Subject } from 'rxjs';
    
    import { TranslateService } from '@ngx-translate/core';
    
    import { FileService } from 'common/services/file.service';
    
    import { MessageService } from 'common/services/message.service';
    import { ArrayUtils } from 'common/util/array';
    
    import { SanitizationService } from 'common/services/sanitization.service';
    
    import { Unit } from 'common/models/unit';
    import {
    
      DragNDropValueObject, InputElement,
    
    rhenck's avatar
    rhenck committed
      InputElementValue, TextLabel, PlayerElement, PlayerProperties, PositionedUIElement,
    
    rhenck's avatar
    rhenck committed
      UIElement, UIElementType, UIElementValue, Hotspot, PositionProperties
    
    } from 'common/models/elements/element';
    import { ClozeDocument, ClozeElement } from 'common/models/elements/compound-elements/cloze/cloze';
    import { LikertRowElement } from 'common/models/elements/compound-elements/likert/likert-row';
    import { TextElement } from 'common/models/elements/text/text';
    import { DropListElement } from 'common/models/elements/input-elements/drop-list';
    import { Page } from 'common/models/page';
    import { Section } from 'common/models/section';
    
    rhenck's avatar
    rhenck committed
    import { ElementFactory } from 'common/util/element.factory';
    
    rhenck's avatar
    rhenck committed
    import { DialogService } from './dialog.service';
    import { VeronaAPIService } from './verona-api.service';
    import { SelectionService } from './selection.service';
    
    rhenck's avatar
    rhenck committed
    import { IDService } from './id.service';
    
    
    @Injectable({
      providedIn: 'root'
    })
    export class UnitService {
    
    rhenck's avatar
    rhenck committed
      unit: Unit;
    
      elementPropertyUpdated: Subject<void> = new Subject<void>();
    
      geometryElementPropertyUpdated: Subject<string> = new Subject<string>();
    
      constructor(private selectionService: SelectionService,
                  private veronaApiService: VeronaAPIService,
    
                  private messageService: MessageService,
    
                  private dialogService: DialogService,
    
                  private sanitizationService: SanitizationService,
    
                  private sanitizer: DomSanitizer,
    
    rhenck's avatar
    rhenck committed
                  private translateService: TranslateService,
                  private idService: IDService) {
    
        this.unit = new Unit();
    
      loadUnitDefinition(unitDefinition: string): void {
    
    rhenck's avatar
    rhenck committed
        this.idService.reset();
    
          try {
            const unitDef = JSON.parse(unitDefinition);
    
            this.unit = new Unit(unitDef);
    
          } catch (e) {
    
            console.error(e);
    
            this.messageService.showError('Unit definition konnte nicht gelesen werden!');
            this.unit = new Unit();
    
          this.unit = new Unit();
    
    rhenck's avatar
    rhenck committed
        this.idService.registerUnitIds(this.unit);
    
      unitUpdated(): void {
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
    rhenck's avatar
    rhenck committed
      addSection(page: Page, newSection?: Partial<Section>): void {
    
        page.sections.push(new Section(newSection as Record<string, UIElementValue>));
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      deleteSection(section: Section): void {
    
        this.unit.pages[this.selectionService.selectedPageIndex].sections.splice(
          this.unit.pages[this.selectionService.selectedPageIndex].sections.indexOf(section),
          1
        );
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      duplicateSection(section: Section, page: Page, sectionIndex: number): void {
    
        const newSection: Section = new Section({
    
          elements: section.elements.map(element => this.duplicateElement(element))
    
    rhenck's avatar
    rhenck committed
        page.sections.splice(sectionIndex + 1, 0, newSection);
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      moveSection(section: Section, page: Page, direction: 'up' | 'down'): void {
    
        ArrayUtils.moveArrayItem(section, page.sections, direction);
    
        if (direction === 'up' && this.selectionService.selectedPageSectionIndex > 0) {
          this.selectionService.selectedPageSectionIndex -= 1;
        } else if (direction === 'down') {
          this.selectionService.selectedPageSectionIndex += 1;
        }
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
    rhenck's avatar
    rhenck committed
      addElementToSectionByIndex(elementType: UIElementType,
                                 pageIndex: number,
                                 sectionIndex: number): void {
    
    rhenck's avatar
    rhenck committed
        this.addElementToSection(elementType, this.unit.pages[pageIndex].sections[sectionIndex]);
    
    rhenck's avatar
    rhenck committed
      async addElementToSection(elementType: UIElementType,
    
                                section: Section,
    
                                coordinates?: { x: number, y: number }): Promise<void> {
    
    rhenck's avatar
    rhenck committed
        const newElement: { type: string } & Partial<PositionedUIElement> = {
          type: elementType
        };
    
        if (['geometry'].includes(elementType)) {
          newElement.appDefinition = await firstValueFrom(this.dialogService.showGeogebraAppDefinitionDialog());
          if (!newElement.appDefinition) return; // dialog canceled
        }
    
    jojohoch's avatar
    jojohoch committed
        if (['audio', 'video', 'image', 'hotspot-image'].includes(elementType)) {
    
    rhenck's avatar
    rhenck committed
          let mediaSrc = '';
          switch (elementType) {
    
    jojohoch's avatar
    jojohoch committed
            case 'hotspot-image':
    
    rhenck's avatar
    rhenck committed
            case 'image':
              mediaSrc = await FileService.loadImage();
              break;
            case 'audio':
              mediaSrc = await FileService.loadAudio();
              break;
            case 'video':
              mediaSrc = await FileService.loadVideo();
              break;
            // no default
          }
    
    rhenck's avatar
    rhenck committed
          newElement.src = mediaSrc;
    
    rhenck's avatar
    rhenck committed
        }
    
    rhenck's avatar
    rhenck committed
    
        if (coordinates) {
    
    rhenck's avatar
    rhenck committed
          newElement.position = {
    
    rhenck's avatar
    rhenck committed
            ...(section.dynamicPositioning && { gridColumn: coordinates.x }),
            ...(section.dynamicPositioning && { gridRow: coordinates.y }),
            ...(!section.dynamicPositioning && { yPosition: coordinates.y }),
            ...(!section.dynamicPositioning && { yPosition: coordinates.y })
    
    rhenck's avatar
    rhenck committed
          } as PositionProperties;
    
    rhenck's avatar
    rhenck committed
        }
    
        section.addElement(ElementFactory.createElement({
    
    rhenck's avatar
    rhenck committed
          ...newElement,
    
          id: this.idService.getAndRegisterNewID(newElement.type),
    
    rhenck's avatar
    rhenck committed
          position: { ...newElement.position } as PositionProperties
    
        }) as PositionedUIElement);
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      deleteElements(elements: UIElement[]): void {
    
        this.freeUpIds(elements);
    
    rhenck's avatar
    rhenck committed
        this.unit.pages[this.selectionService.selectedPageIndex].sections.forEach(section => {
    
          section.elements = section.elements.filter(element => !elements.includes(element));
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
    rhenck's avatar
    rhenck committed
      private freeUpIds(elements: UIElement[]): void {
    
    rhenck's avatar
    rhenck committed
        elements.forEach(element => {
          if (element.type === 'drop-list') {
    
            ((element as DropListElement).value as DragNDropValueObject[]).forEach((value: DragNDropValueObject) => {
    
    rhenck's avatar
    rhenck committed
              this.idService.removeId(value.id);
    
          if (element instanceof CompoundElement) {
            element.getChildElements().forEach((childElement: UIElement) => {
    
    rhenck's avatar
    rhenck committed
              this.idService.removeId(childElement.id);
    
    rhenck's avatar
    rhenck committed
          this.idService.removeId(element.id);
    
      /* Move element between sections */
    
      transferElement(elements: UIElement[], previousSection: Section, newSection: Section): void {
    
        previousSection.elements = previousSection.elements.filter(element => !elements.includes(element));
    
        elements.forEach(element => {
    
          newSection.elements.push(element as PositionedUIElement);
          (element as PositionedUIElement).position.dynamicPositioning = newSection.dynamicPositioning;
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      duplicateElementsInSection(elements: UIElement[], pageIndex: number, sectionIndex: number): void {
    
    rhenck's avatar
    rhenck committed
        const section = this.unit.pages[pageIndex].sections[sectionIndex];
    
        elements.forEach((element: UIElement) => {
    
          section.elements.push(this.duplicateElement(element) as PositionedUIElement);
    
        });
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
      }
    
      private duplicateElement(element: UIElement): UIElement {
    
        const newElement = ElementFactory.createElement(element);
    
    rhenck's avatar
    rhenck committed
        newElement.id = this.idService.getAndRegisterNewID(newElement.type);
    
    
        if (newElement.position) {
          newElement.position.xPosition += 10;
          newElement.position.yPosition += 10;
        }
    
    rhenck's avatar
    rhenck committed
        if (newElement.type === 'likert') { // replace row Ids with fresh ones (likert)
    
    rhenck's avatar
    rhenck committed
          (newElement.rows as LikertRowElement[]).forEach((rowObject: { id: string }) => {
    
    rhenck's avatar
    rhenck committed
            rowObject.id = this.idService.getAndRegisterNewID('likert-row');
          });
        }
        if (newElement.type === 'cloze') {
          ClozeElement.getDocumentChildElements((newElement as ClozeElement).document).forEach(clozeChild => {
            clozeChild.id = this.idService.getAndRegisterNewID(clozeChild.type);
    
    rhenck's avatar
    rhenck committed
        if (newElement.type === 'drop-list') {
          (newElement.value as DragNDropValueObject[]).forEach(valueObject => {
            valueObject.id = this.idService.getAndRegisterNewID('value');
          });
        }
    
      updateSectionProperty(section: Section, property: string, value: string | number | boolean | { value: number; unit: string }[]): void {
    
        if (property === 'dynamicPositioning') {
          section.dynamicPositioning = value as boolean;
    
          section.elements.forEach((element: UIElement) => {
            (element as PositionedUIElement).position.dynamicPositioning = value as boolean;
    
          section.setProperty(property, value);
    
        this.elementPropertyUpdated.next();
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
      updateElementsProperty(elements: UIElement[],
                             property: string,
    
    jojohoch's avatar
    jojohoch committed
                             value: InputElementValue | LikertRowElement[] | Hotspot[] |
    
                             TextLabel | TextLabel[] | ClozeDocument | null): void {
        console.log('updateElementProperty', elements, property, value);
    
        elements.forEach(element => {
    
          if (property === 'id') {
    
    rhenck's avatar
    rhenck committed
            if (!this.idService.isIdAvailable((value as string))) { // prohibit existing IDs
    
              this.messageService.showError(this.translateService.instant('idTaken'));
    
            } else if ((value as string).length > 20) {
              this.messageService.showError('ID länger als 20 Zeichen');
            } else if ((value as string).includes(' ')) {
              this.messageService.showError('ID enthält unerlaubtes Leerzeichen');
    
    rhenck's avatar
    rhenck committed
              this.idService.removeId(element.id);
              this.idService.addID(value as string);
    
              element.id = value as string;
    
    rhenck's avatar
    rhenck committed
          } else if (property === 'document') {
            element.setProperty(property, value);
            ClozeElement.getDocumentChildElements(value as ClozeDocument).forEach(clozeChild => {
              if (clozeChild.id === 'cloze-child-id-placeholder') {
                clozeChild.id = this.idService.getAndRegisterNewID(clozeChild.type);
              }
            });
    
    rhenck's avatar
    rhenck committed
          } else {
    
            element.setProperty(property, value);
    
            if (element.type === 'geometry') this.geometryElementPropertyUpdated.next(element.id);
    
        this.elementPropertyUpdated.next();
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
    rhenck's avatar
    rhenck committed
      updateSelectedElementsPositionProperty(property: string, value: UIElementValue): void {
    
        this.updateElementsPositionProperty(this.selectionService.getSelectedElements(), property, value);
      }
    
    
    rhenck's avatar
    rhenck committed
      updateElementsPositionProperty(elements: UIElement[], property: string, value: UIElementValue): void {
    
        elements.forEach(element => {
          element.setPositionProperty(property, value);
        });
    
        this.elementPropertyUpdated.next();
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
      }
    
    
      reorderElements() {
        const sectionElementList = this.unit.pages[this.selectionService.selectedPageIndex]
          .sections[this.selectionService.selectedPageSectionIndex].elements;
        const isDynamicPositioning = this.unit.pages[this.selectionService.selectedPageIndex]
          .sections[this.selectionService.selectedPageSectionIndex].dynamicPositioning;
        const sortDynamicPositioning = (a: PositionedUIElement, b: PositionedUIElement) => {
          const rowSort =
            (a.position.gridRow !== null ? a.position.gridRow : Infinity) -
            (b.position.gridRow !== null ? b.position.gridRow : Infinity);
          if (rowSort === 0) {
            return a.position.gridColumn! - b.position.gridColumn!;
          }
          return rowSort;
        };
        const sortStaticPositioning = (a: PositionedUIElement, b: PositionedUIElement) => {
          const ySort = a.position.yPosition! - b.position.yPosition!;
          if (ySort === 0) {
            return a.position.xPosition! - b.position.xPosition!;
          }
          return ySort;
        };
        if (isDynamicPositioning) {
          sectionElementList.sort(sortDynamicPositioning);
        } else {
          sectionElementList.sort(sortStaticPositioning);
        }
      }
    
    
    rhenck's avatar
    rhenck committed
      updateSelectedElementsStyleProperty(property: string, value: UIElementValue): void {
    
        const elements = this.selectionService.getSelectedElements();
        elements.forEach(element => {
          element.setStyleProperty(property, value);
        });
        this.elementPropertyUpdated.next();
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
      }
    
    
    rhenck's avatar
    rhenck committed
      updateElementsPlayerProperty(elements: UIElement[], property: string, value: UIElementValue): void {
    
        elements.forEach(element => {
          element.setPlayerProperty(property, value);
        });
        this.elementPropertyUpdated.next();
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
      }
    
      alignElements(elements: PositionedUIElement[], alignmentDirection: 'left' | 'right' | 'top' | 'bottom'): void {
    
    rhenck's avatar
    rhenck committed
        switch (alignmentDirection) {
          case 'left':
    
            this.updateElementsProperty(
    
    rhenck's avatar
    rhenck committed
              elements,
              'xPosition',
    
              Math.min(...elements.map(element => element.position.xPosition))
    
    rhenck's avatar
    rhenck committed
            );
            break;
          case 'right':
    
            this.updateElementsProperty(
    
    rhenck's avatar
    rhenck committed
              elements,
              'xPosition',
    
              Math.max(...elements.map(element => element.position.xPosition))
    
    rhenck's avatar
    rhenck committed
            );
            break;
          case 'top':
    
            this.updateElementsProperty(
    
    rhenck's avatar
    rhenck committed
              elements,
              'yPosition',
    
              Math.min(...elements.map(element => element.position.yPosition))
    
    rhenck's avatar
    rhenck committed
            );
            break;
          case 'bottom':
    
            this.updateElementsProperty(
    
    rhenck's avatar
    rhenck committed
              elements,
              'yPosition',
    
              Math.max(...elements.map(element => element.position.yPosition))
    
    rhenck's avatar
    rhenck committed
            );
            break;
          // no default
        }
    
        this.elementPropertyUpdated.next();
    
        this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
    
    rhenck's avatar
    rhenck committed
        FileService.saveUnitToFile(JSON.stringify(this.unit));
    
      async loadUnitFromFile(): Promise<void> {
    
        this.loadUnitDefinition(await FileService.loadFile(['.json', '.voud']));
    
      showDefaultEditDialog(element: UIElement): void {
    
        switch (element.type) {
          case 'button':
          case 'dropdown':
    
          case 'checkbox':
          case 'radio':
    
            this.dialogService.showTextEditDialog(element.label as string).subscribe((result: string) => {
    
              if (result) {
    
                this.updateElementsProperty([element], 'label', result);
    
              }
            });
            break;
          case 'text':
    
            this.dialogService.showRichTextEditDialog(
    
              (element as TextElement).text,
    
              (element as TextElement).styling.fontSize
    
            ).subscribe((result: string) => {
    
              if (result) {
    
                // TODO add proper sanitization
    
                this.updateElementsProperty(
    
                  [element],
                  'text',
                  (this.sanitizer.bypassSecurityTrustHtml(result) as any).changingThisBreaksApplicationSecurity as string
                );
    
    rhenck's avatar
    rhenck committed
          case 'cloze':
    
    rhenck's avatar
    rhenck committed
            this.dialogService.showClozeTextEditDialog(
    
              (element as ClozeElement).document!,
    
              (element as ClozeElement).styling.fontSize
    
    rhenck's avatar
    rhenck committed
            ).subscribe((result: string) => {
    
    rhenck's avatar
    rhenck committed
              if (result) {
                // TODO add proper sanitization
    
                this.updateElementsProperty(
    
    rhenck's avatar
    rhenck committed
                  [element],
    
    rhenck's avatar
    rhenck committed
                  'document',
    
    rhenck's avatar
    rhenck committed
                  (this.sanitizer.bypassSecurityTrustHtml(result) as any).changingThisBreaksApplicationSecurity as string
                );
              }
            });
            break;
    
          case 'text-field':
    
            this.dialogService.showTextEditDialog((element as InputElement).value as string)
              .subscribe((result: string) => {
                if (result) {
    
                  this.updateElementsProperty([element], 'value', result);
    
            break;
          case 'text-area':
    
            this.dialogService.showMultilineTextEditDialog((element as InputElement).value as string)
              .subscribe((result: string) => {
                if (result) {
    
                  this.updateElementsProperty([element], 'value', result);
    
            this.dialogService.showPlayerEditDialog((element as PlayerElement).player)
    
              .subscribe((result: PlayerProperties) => {
    
    rhenck's avatar
    rhenck committed
                Object.keys(result).forEach(
                  key => this.updateElementsPlayerProperty([element], key, result[key] as UIElementValue)
                );
    
          // no default
    
    
      getNewValueID(): string {
    
    rhenck's avatar
    rhenck committed
        return this.idService.getAndRegisterNewID('value');
    
    
      /* Used by props panel to show available dropLists to connect */
      getDropListElementIDs(): string[] {
    
        const allDropLists = [
          ...this.unit.getAllElements('drop-list'),
          ...this.unit.getAllElements('drop-list-simple')];
        return allDropLists.map(dropList => dropList.id);
    
    
      replaceSection(pageIndex: number, sectionIndex: number, newSection: Section): void {
    
    rhenck's avatar
    rhenck committed
        this.deleteSection(this.unit.pages[pageIndex].sections[sectionIndex]);
        this.addSection(this.unit.pages[pageIndex], newSection);