From cc81c788be8dd6f996d7fc54d6f15ba595521ecc Mon Sep 17 00:00:00 2001
From: rhenck <richard.henck@iqb.hu-berlin.de>
Date: Fri, 7 Oct 2022 16:43:49 +0200
Subject: [PATCH] Add buttons as allowed cloze children

- Make position props on buttons optional
- Move UIElement creation back to Factory instead of within Section
---
 .../components/button/button.component.ts     |  4 +-
 .../cloze/cloze.component.ts                  |  2 +-
 .../cloze/compound-child-overlay.component.ts |  6 ++
 .../common/models/elements/button/button.ts   |  8 +--
 .../elements/compound-elements/cloze/cloze.ts | 14 +++--
 .../cloze/tiptap-editor-extensions/button.ts  | 26 +++++++++
 projects/common/models/section.ts             | 35 ++----------
 projects/common/util/element.factory.ts       | 56 ++++++++++++++++++-
 projects/editor/src/app/app.module.ts         |  2 +
 .../editor/src/app/services/unit.service.ts   |  9 +--
 .../button-component-extension.ts             | 33 +++++++++++
 .../button-nodeview.component.ts              | 13 +++++
 .../rich-text-editor.component.html           |  4 ++
 .../text-editor/rich-text-editor.component.ts |  8 +++
 14 files changed, 173 insertions(+), 47 deletions(-)
 create mode 100644 projects/common/models/elements/compound-elements/cloze/tiptap-editor-extensions/button.ts
 create mode 100644 projects/editor/src/app/text-editor/angular-node-views/button-component-extension.ts
 create mode 100644 projects/editor/src/app/text-editor/angular-node-views/button-nodeview.component.ts

diff --git a/projects/common/components/button/button.component.ts b/projects/common/components/button/button.component.ts
index efb0e597e..e17e671e6 100644
--- a/projects/common/components/button/button.component.ts
+++ b/projects/common/components/button/button.component.ts
@@ -50,8 +50,8 @@ import { ButtonElement } from 'common/models/elements/button/button';
     <input
         *ngIf="elementModel.imageSrc" type="image"
         [src]="elementModel.imageSrc | safeResourceUrl"
-        [class]="elementModel.position.dynamicPositioning &&
-                    !elementModel.position.fixedSize ? 'dynamic-image' : 'static-image'"
+        [class]="elementModel.position?.dynamicPositioning &&
+                 !elementModel.position?.fixedSize ? 'dynamic-image' : 'static-image'"
         [alt]="'imageNotFound' | translate"
         (click)="elementModel.action && elementModel.actionParam !== null?
            navigateTo.emit({
diff --git a/projects/common/components/compound-elements/cloze/cloze.component.ts b/projects/common/components/compound-elements/cloze/cloze.component.ts
index 9d7ebbde1..10bd922d0 100644
--- a/projects/common/components/compound-elements/cloze/cloze.component.ts
+++ b/projects/common/components/compound-elements/cloze/cloze.component.ts
@@ -155,7 +155,7 @@ import { ClozeElement } from 'common/models/elements/compound-elements/cloze/clo
                [style.height]="'1em'"
                [style.vertical-align]="'middle'">
         </ng-container>
-        <span *ngIf="['ToggleButton', 'DropList', 'TextField'].includes(subPart.type)">
+        <span *ngIf="['ToggleButton', 'DropList', 'TextField', 'Button'].includes(subPart.type)">
           <aspect-compound-child-overlay [style.display]="'inline-block'"
                                          [parentForm]="parentForm"
                                          [element]="$any(subPart).attrs.model"
diff --git a/projects/common/components/compound-elements/cloze/compound-child-overlay.component.ts b/projects/common/components/compound-elements/cloze/compound-child-overlay.component.ts
index 7f595f33d..f41139468 100644
--- a/projects/common/components/compound-elements/cloze/compound-child-overlay.component.ts
+++ b/projects/common/components/compound-elements/cloze/compound-child-overlay.component.ts
@@ -35,6 +35,12 @@ import { ValueChangeElement } from 'common/models/elements/element';
                             [style.width]="element.dynamicWidth ? 'unset' : element.width+'px'"
                             [style.height.px]="element.height">
       </aspect-toggle-button>
+      <aspect-button *ngIf="element.type === 'button'" #childComponent
+                     [style.pointer-events]="editorMode ? 'none' : 'auto'"
+                     [elementModel]="$any(element)"
+                     [style.width.px]="element.width"
+                     [style.height.px]="element.height">
+      </aspect-button>
     </div>
   `,
   styles: [
diff --git a/projects/common/models/elements/button/button.ts b/projects/common/models/elements/button/button.ts
index 0b92465a1..b5bdcc645 100644
--- a/projects/common/models/elements/button/button.ts
+++ b/projects/common/models/elements/button/button.ts
@@ -1,18 +1,18 @@
 import { Type } from '@angular/core';
 import { ElementFactory } from 'common/util/element.factory';
 import {
-  BasicStyles, PositionedUIElement, PositionProperties, UIElement
+  BasicStyles, PositionProperties, UIElement
 } from 'common/models/elements/element';
 import { ButtonComponent } from 'common/components/button/button.component';
 import { ElementComponent } from 'common/directives/element-component.directive';
 
-export class ButtonElement extends UIElement implements PositionedUIElement {
+export class ButtonElement extends UIElement {
   label: string = 'Navigationsknopf';
   imageSrc: string | null = null;
   asLink: boolean = false;
   action: null | 'unitNav' | 'pageNav' = null;
   actionParam: null | 'previous' | 'next' | 'first' | 'last' | 'end' | number = null;
-  position: PositionProperties;
+  position: PositionProperties | undefined;
   styling: BasicStyles & {
     borderRadius: number;
   };
@@ -24,7 +24,7 @@ export class ButtonElement extends UIElement implements PositionedUIElement {
     if (element.asLink) this.asLink = element.asLink;
     if (element.action) this.action = element.action;
     if (element.actionParam) this.actionParam = element.actionParam;
-    this.position = ElementFactory.initPositionProps(element.position);
+    this.position = element.position ? ElementFactory.initPositionProps(element.position) : undefined;
     this.styling = {
       ...ElementFactory.initStylingProps<{ borderRadius: number; }>({ borderRadius: 0, ...element.styling })
     };
diff --git a/projects/common/models/elements/compound-elements/cloze/cloze.ts b/projects/common/models/elements/compound-elements/cloze/cloze.ts
index 3c363a6eb..e55f6ebc2 100644
--- a/projects/common/models/elements/compound-elements/cloze/cloze.ts
+++ b/projects/common/models/elements/compound-elements/cloze/cloze.ts
@@ -17,6 +17,7 @@ import {
   DropListSimpleElement
 } from 'common/models/elements/compound-elements/cloze/cloze-child-elements/drop-list-simple';
 import { ToggleButtonElement } from 'common/models/elements/compound-elements/cloze/cloze-child-elements/toggle-button';
+import { ButtonElement } from 'common/models/elements/button/button';
 
 export class ClozeElement extends CompoundElement implements PositionedUIElement {
   document: ClozeDocument = { type: 'doc', content: [] };
@@ -61,7 +62,7 @@ export class ClozeElement extends CompoundElement implements PositionedUIElement
 
   private static createSubNodeElements(node: any) {
     node.content?.forEach((subNode: any) => {
-      if (['ToggleButton', 'DropList', 'TextField'].includes(subNode.type) &&
+      if (['ToggleButton', 'DropList', 'TextField', 'Button'].includes(subNode.type) &&
         subNode.attrs.model.id === 'cloze-child-id-placeholder') {
         subNode.attrs.model =
           ClozeElement.createChildElement({ ...subNode.attrs.model });
@@ -78,7 +79,7 @@ export class ClozeElement extends CompoundElement implements PositionedUIElement
           ...paragraph,
           content: paragraph.content ? paragraph.content
             .map((paraPart: ClozeDocumentParagraphPart) => (
-              ['TextField', 'DropList', 'ToggleButton'].includes(paraPart.type) ?
+              ['TextField', 'DropList', 'ToggleButton', 'Button'].includes(paraPart.type) ?
                 {
                   ...paraPart,
                   attrs: {
@@ -139,8 +140,8 @@ export class ClozeElement extends CompoundElement implements PositionedUIElement
     return elementList;
   }
 
-  private static createChildElement(elementModel: Partial<UIElement>): InputElement {
-    let newElement: InputElement;
+  private static createChildElement(elementModel: Partial<UIElement>): InputElement | ButtonElement {
+    let newElement: InputElement | ButtonElement;
     switch (elementModel.type) {
       case 'text-field-simple':
         newElement = new TextFieldSimpleElement(elementModel as TextFieldSimpleElement);
@@ -151,6 +152,9 @@ export class ClozeElement extends CompoundElement implements PositionedUIElement
       case 'toggle-button':
         newElement = new ToggleButtonElement(elementModel as ToggleButtonElement);
         break;
+      case 'button':
+        newElement = new ButtonElement(elementModel as ButtonElement);
+        break;
       default:
         throw new Error(`ElementType ${elementModel.type} not found!`);
     }
@@ -162,7 +166,7 @@ export class ClozeElement extends CompoundElement implements PositionedUIElement
       return [];
     }
     return documentPart.content
-      .filter((word: ClozeDocumentParagraphPart) => ['TextField', 'DropList', 'ToggleButton'].includes(word.type))
+      .filter((word: ClozeDocumentParagraphPart) => ['TextField', 'DropList', 'ToggleButton', 'Button'].includes(word.type))
       .reduce((accumulator: any[], currentValue: any) => accumulator.concat(currentValue.attrs.model), []);
   }
 }
diff --git a/projects/common/models/elements/compound-elements/cloze/tiptap-editor-extensions/button.ts b/projects/common/models/elements/compound-elements/cloze/tiptap-editor-extensions/button.ts
new file mode 100644
index 000000000..807c445b3
--- /dev/null
+++ b/projects/common/models/elements/compound-elements/cloze/tiptap-editor-extensions/button.ts
@@ -0,0 +1,26 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { ButtonElement } from 'common/models/elements/button/button';
+
+const ButtonExtension =
+  Node.create({
+    group: 'inline',
+    inline: true,
+    name: 'Button',
+
+    addAttributes() {
+      return {
+        model: {
+          default: new ButtonElement({ type: 'button' })
+        }
+      };
+    },
+
+    parseHTML() {
+      return [{ tag: 'aspect-nodeview-button' }];
+    },
+    renderHTML({ HTMLAttributes }) {
+      return ['aspect-nodeview-button', mergeAttributes(HTMLAttributes)];
+    }
+  });
+
+export default ButtonExtension;
diff --git a/projects/common/models/section.ts b/projects/common/models/section.ts
index 96ef87fc0..371ff70a5 100644
--- a/projects/common/models/section.ts
+++ b/projects/common/models/section.ts
@@ -27,6 +27,7 @@ import { SpellCorrectElement } from 'common/models/elements/input-elements/spell
 import { FrameElement } from 'common/models/elements/frame/frame';
 import { ToggleButtonElement } from 'common/models/elements/compound-elements/cloze/cloze-child-elements/toggle-button';
 import { GeometryElement } from 'common/models/elements/geometry/geometry';
+import { ElementFactory } from 'common/util/element.factory';
 
 export class Section {
   [index: string]: unknown;
@@ -40,30 +41,6 @@ export class Section {
   gridRowSizes: string = '1fr';
   activeAfterID: string | null = null;
 
-  static ELEMENT_CLASSES: Record<string, Type<UIElement>> = {
-    text: TextElement,
-    button: ButtonElement,
-    'text-field': TextFieldElement,
-    'text-field-simple': TextFieldSimpleElement,
-    'text-area': TextAreaElement,
-    checkbox: CheckboxElement,
-    dropdown: DropdownElement,
-    radio: RadioButtonGroupElement,
-    image: ImageElement,
-    audio: AudioElement,
-    video: VideoElement,
-    likert: LikertElement,
-    'radio-group-images': RadioButtonGroupComplexElement,
-    'drop-list': DropListElement,
-    'drop-list-simple': DropListSimpleElement,
-    cloze: ClozeElement,
-    slider: SliderElement,
-    'spell-correct': SpellCorrectElement,
-    frame: FrameElement,
-    'toggle-button': ToggleButtonElement,
-    geometry: GeometryElement
-  };
-
   constructor(section?: Partial<Section>) {
     if (section?.height) this.height = section.height;
     if (section?.backgroundColor) this.backgroundColor = section.backgroundColor;
@@ -74,12 +51,10 @@ export class Section {
     if (section?.gridRowSizes !== undefined) this.gridRowSizes = section.gridRowSizes;
     if (section?.activeAfterID) this.activeAfterID = section.activeAfterID;
     this.elements =
-      section?.elements?.map(element => Section.createElement(element)) ||
-      [];
-  }
-
-  static createElement(element: { type: string } & Partial<UIElement>): PositionedUIElement {
-    return new Section.ELEMENT_CLASSES[element.type](element) as PositionedUIElement;
+      section?.elements?.map(element => ({
+        ...ElementFactory.createElement(element),
+        position: ElementFactory.initPositionProps(element.position)
+      } as PositionedUIElement)) || [];
   }
 
   setProperty(property: string, value: UIElementValue): void {
diff --git a/projects/common/util/element.factory.ts b/projects/common/util/element.factory.ts
index f0a15675a..8fd89b80e 100644
--- a/projects/common/util/element.factory.ts
+++ b/projects/common/util/element.factory.ts
@@ -1,8 +1,62 @@
 import {
-  BasicStyles, PlayerProperties, PositionProperties, TextImageLabel
+  BasicStyles, PlayerProperties, PositionedUIElement, PositionProperties, TextImageLabel, UIElement
 } from 'common/models/elements/element';
+import { Type } from '@angular/core';
+import { TextElement } from 'common/models/elements/text/text';
+import { ButtonElement } from 'common/models/elements/button/button';
+import { TextFieldElement } from 'common/models/elements/input-elements/text-field';
+import {
+  TextFieldSimpleElement
+} from 'common/models/elements/compound-elements/cloze/cloze-child-elements/text-field-simple';
+import { TextAreaElement } from 'common/models/elements/input-elements/text-area';
+import { CheckboxElement } from 'common/models/elements/input-elements/checkbox';
+import { DropdownElement } from 'common/models/elements/input-elements/dropdown';
+import { RadioButtonGroupElement } from 'common/models/elements/input-elements/radio-button-group';
+import { ImageElement } from 'common/models/elements/media-elements/image';
+import { AudioElement } from 'common/models/elements/media-elements/audio';
+import { VideoElement } from 'common/models/elements/media-elements/video';
+import { LikertElement } from 'common/models/elements/compound-elements/likert/likert';
+import { RadioButtonGroupComplexElement } from 'common/models/elements/input-elements/radio-button-group-complex';
+import { DropListElement } from 'common/models/elements/input-elements/drop-list';
+import {
+  DropListSimpleElement
+} from 'common/models/elements/compound-elements/cloze/cloze-child-elements/drop-list-simple';
+import { ClozeElement } from 'common/models/elements/compound-elements/cloze/cloze';
+import { SliderElement } from 'common/models/elements/input-elements/slider';
+import { SpellCorrectElement } from 'common/models/elements/input-elements/spell-correct';
+import { FrameElement } from 'common/models/elements/frame/frame';
+import { ToggleButtonElement } from 'common/models/elements/compound-elements/cloze/cloze-child-elements/toggle-button';
+import { GeometryElement } from 'common/models/elements/geometry/geometry';
 
 export abstract class ElementFactory {
+  static ELEMENT_CLASSES: Record<string, Type<UIElement>> = {
+    text: TextElement,
+    button: ButtonElement,
+    'text-field': TextFieldElement,
+    'text-field-simple': TextFieldSimpleElement,
+    'text-area': TextAreaElement,
+    checkbox: CheckboxElement,
+    dropdown: DropdownElement,
+    radio: RadioButtonGroupElement,
+    image: ImageElement,
+    audio: AudioElement,
+    video: VideoElement,
+    likert: LikertElement,
+    'radio-group-images': RadioButtonGroupComplexElement,
+    'drop-list': DropListElement,
+    'drop-list-simple': DropListSimpleElement,
+    cloze: ClozeElement,
+    slider: SliderElement,
+    'spell-correct': SpellCorrectElement,
+    frame: FrameElement,
+    'toggle-button': ToggleButtonElement,
+    geometry: GeometryElement
+  };
+
+  static createElement(element: { type: string } & Partial<UIElement>): UIElement {
+    return new ElementFactory.ELEMENT_CLASSES[element.type](element);
+  }
+
   static initPositionProps(defaults: Partial<PositionProperties> = {}): PositionProperties {
     return {
       fixedSize: defaults.fixedSize !== undefined ? defaults.fixedSize as boolean : false,
diff --git a/projects/editor/src/app/app.module.ts b/projects/editor/src/app/app.module.ts
index a42abb5f0..df8cdcf8c 100644
--- a/projects/editor/src/app/app.module.ts
+++ b/projects/editor/src/app/app.module.ts
@@ -47,6 +47,7 @@ import { DropListOptionEditDialogComponent } from './components/dialogs/drop-lis
 import { ToggleButtonNodeviewComponent } from './text-editor/angular-node-views/toggle-button-nodeview.component';
 import { TextFieldNodeviewComponent } from './text-editor/angular-node-views/text-field-nodeview.component';
 import { DropListNodeviewComponent } from './text-editor/angular-node-views/drop-list-nodeview.component';
+import { ButtonNodeviewComponent } from './text-editor/angular-node-views/button-nodeview.component';
 import { PositionFieldSetComponent } from
   './components/properties-panel/position-properties-tab/input-groups/position-field-set.component';
 import { DimensionFieldSetComponent } from
@@ -107,6 +108,7 @@ import { VeronaAPIService } from 'editor/src/app/services/verona-api.service';
     ToggleButtonNodeviewComponent,
     TextFieldNodeviewComponent,
     DropListNodeviewComponent,
+    ButtonNodeviewComponent,
     ElementStylePropertiesComponent,
     ElementPositionPropertiesComponent,
     ConfirmationDialogComponent,
diff --git a/projects/editor/src/app/services/unit.service.ts b/projects/editor/src/app/services/unit.service.ts
index e6c5a6cb9..64f7562fa 100644
--- a/projects/editor/src/app/services/unit.service.ts
+++ b/projects/editor/src/app/services/unit.service.ts
@@ -134,10 +134,11 @@ export class UnitService {
         ...(!section.dynamicPositioning && { yPosition: coordinates.y })
       });
     }
-    section.addElement(Section.createElement({
+    section.addElement(ElementFactory.createElement({
       ...newElement,
-      id: this.idService.getAndRegisterNewID(newElement.type)
-    }));
+      id: this.idService.getAndRegisterNewID(newElement.type),
+      position: ElementFactory.initPositionProps(newElement.position)
+    }) as PositionedUIElement);
     this.veronaApiService.sendVoeDefinitionChangedNotification(this.unit);
   }
 
@@ -184,7 +185,7 @@ export class UnitService {
   }
 
   private duplicateElement(element: UIElement): UIElement {
-    const newElement = Section.createElement(element);
+    const newElement = ElementFactory.createElement(element);
     newElement.id = this.idService.getAndRegisterNewID(newElement.type);
 
     if (newElement.position) {
diff --git a/projects/editor/src/app/text-editor/angular-node-views/button-component-extension.ts b/projects/editor/src/app/text-editor/angular-node-views/button-component-extension.ts
new file mode 100644
index 000000000..952371675
--- /dev/null
+++ b/projects/editor/src/app/text-editor/angular-node-views/button-component-extension.ts
@@ -0,0 +1,33 @@
+import { Injector } from '@angular/core';
+import { Node, mergeAttributes } from '@tiptap/core';
+import { AngularNodeViewRenderer } from 'ngx-tiptap';
+import { ButtonElement } from 'common/models/elements/button/button';
+import { ButtonNodeviewComponent } from 'editor/src/app/text-editor/angular-node-views/button-nodeview.component';
+
+const ButtonComponentExtension = (injector: Injector): Node => {
+  return Node.create({
+    group: 'inline',
+    inline: true,
+    name: 'Button',
+
+    addAttributes() {
+      return {
+        model: {
+          default: new ButtonElement({ type: 'button', id: 'cloze-child-id-placeholder', height: 34 })
+        }
+      };
+    },
+
+    parseHTML() {
+      return [{ tag: 'aspect-nodeview-button' }];
+    },
+    renderHTML({ HTMLAttributes }) {
+      return ['aspect-nodeview-button', mergeAttributes(HTMLAttributes)];
+    },
+    addNodeView() {
+      return AngularNodeViewRenderer(ButtonNodeviewComponent, { injector });
+    }
+  });
+};
+
+export default ButtonComponentExtension;
diff --git a/projects/editor/src/app/text-editor/angular-node-views/button-nodeview.component.ts b/projects/editor/src/app/text-editor/angular-node-views/button-nodeview.component.ts
new file mode 100644
index 000000000..ac7091924
--- /dev/null
+++ b/projects/editor/src/app/text-editor/angular-node-views/button-nodeview.component.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core';
+import { AngularNodeViewComponent } from 'ngx-tiptap';
+
+@Component({
+  selector: 'aspect-nodeview-button',
+  template: `
+    <aspect-button [style.display]="'inline-block'"
+                   [elementModel]="node.attrs.model"
+                   [matTooltip]="'ID: ' + node.attrs.model.id">
+    </aspect-button>
+  `
+})
+export class ButtonNodeviewComponent extends AngularNodeViewComponent { }
diff --git a/projects/editor/src/app/text-editor/rich-text-editor.component.html b/projects/editor/src/app/text-editor/rich-text-editor.component.html
index 07efd514e..8a7ff1232 100644
--- a/projects/editor/src/app/text-editor/rich-text-editor.component.html
+++ b/projects/editor/src/app/text-editor/rich-text-editor.component.html
@@ -277,6 +277,10 @@
             (click)="insertToggleButton()">
       <mat-icon>radio_button_checked</mat-icon>
     </button>
+    <button mat-icon-button matTooltip="Navigationsknopf" [matTooltipShowDelay]="300"
+            (click)="insertButton()">
+      <mat-icon>navigation</mat-icon>
+    </button>
   </div>
 </div>
 <tiptap-editor [editor]="editor" [ngModel]="content" mat-dialog-content
diff --git a/projects/editor/src/app/text-editor/rich-text-editor.component.ts b/projects/editor/src/app/text-editor/rich-text-editor.component.ts
index 26eda925d..59181bf10 100644
--- a/projects/editor/src/app/text-editor/rich-text-editor.component.ts
+++ b/projects/editor/src/app/text-editor/rich-text-editor.component.ts
@@ -29,6 +29,7 @@ import { OrderedListExtension } from './extensions/ordered-list';
 import ToggleButtonComponentExtension from './angular-node-views/toggle-button-component-extension';
 import DropListComponentExtension from './angular-node-views/drop-list-component-extension';
 import TextFieldComponentExtension from './angular-node-views/text-field-component-extension';
+import ButtonComponentExtension from 'editor/src/app/text-editor/angular-node-views/button-component-extension';
 
 @Component({
   selector: 'aspect-rich-text-editor',
@@ -96,6 +97,7 @@ export class RichTextEditorComponent implements OnInit, AfterViewInit {
       activeExtensions.push(ToggleButtonComponentExtension(this.injector));
       activeExtensions.push(DropListComponentExtension(this.injector));
       activeExtensions.push(TextFieldComponentExtension(this.injector));
+      activeExtensions.push(ButtonComponentExtension(this.injector));
     }
     this.editor = new Editor({
       extensions: activeExtensions,
@@ -234,4 +236,10 @@ export class RichTextEditorComponent implements OnInit, AfterViewInit {
     this.editor.commands.insertContent('<aspect-nodeview-text-field></aspect-nodeview-text-field>');
     this.editor.commands.focus();
   }
+
+  insertButton() {
+    console.log('inserting button');
+    this.editor.commands.insertContent('<aspect-nodeview-button></aspect-nodeview-button>');
+    this.editor.commands.focus();
+  }
 }
-- 
GitLab