From 15cdca5ea99ba03e7aef73f8e38ce6f815946d73 Mon Sep 17 00:00:00 2001
From: rhenck <richard.henck@iqb.hu-berlin.de>
Date: Wed, 12 Jan 2022 21:19:03 +0100
Subject: [PATCH] Rework cloze element data

- Now only has a variable 'document'. This holds the HTML representation
  in an object. This object is enriched with element models.
- Because the the TextEditor extension can neither create multiple
  element instances nor use the IDService to generate their IDs, this
  has to be done afterwards. See ClozeParser.
- The cloze element has rather extensive compatibility handling because
  cloze elements used to save an actual HTML representation. This has to
  be transformed to JSON/object. Therefore we replace the old backslash-
  markers with custom HTML tags. The editor object does this
  transformation. It needs some custom extensions to recognize (and
  don't remove) the HTML tags though.
- Cloze now shows a placeholder text when empty
- The cloze component needs a small pipe to extract text formatting
  options from the paragraph parts.
- For getting the child elements for the player the models have to be
  extracted from the somewhat complex (JSON)document.
- Added some rudimentary interfaces for the TextEditor document format.
- Removed the old ClozePart interface. This is quasi part of the new
  interfaces.
---
 projects/common/models/uI-element.ts          |  26 ++-
 projects/common/shared.module.ts              |  10 +-
 .../common/ui-elements/cloze/cloze-element.ts | 109 ++++++++----
 .../ui-elements/cloze/cloze.component.ts      | 163 ++++++++----------
 .../common/ui-elements/cloze/mark.pipe.ts     |  15 ++
 .../tiptap-editor-extensions/drop-list.ts     |  25 +++
 .../tiptap-editor-extensions/text-field.ts    |  25 +++
 .../tiptap-editor-extensions/toggle-button.ts |  25 +++
 .../editor/src/app/services/unit.service.ts   |  16 +-
 projects/editor/src/app/util/cloze-parser.ts  | 103 ++---------
 10 files changed, 295 insertions(+), 222 deletions(-)
 create mode 100644 projects/common/ui-elements/cloze/mark.pipe.ts
 create mode 100644 projects/common/ui-elements/cloze/tiptap-editor-extensions/drop-list.ts
 create mode 100644 projects/common/ui-elements/cloze/tiptap-editor-extensions/text-field.ts
 create mode 100644 projects/common/ui-elements/cloze/tiptap-editor-extensions/toggle-button.ts

diff --git a/projects/common/models/uI-element.ts b/projects/common/models/uI-element.ts
index 0914e857f..97d423067 100644
--- a/projects/common/models/uI-element.ts
+++ b/projects/common/models/uI-element.ts
@@ -46,7 +46,7 @@ export abstract class UIElement {
   // This can be overwritten by elements if they need to handle some property specifics. Likert does.
   setProperty(property: string,
               value: InputElementValue | LikertColumn[] | LikertRow[] |
-              DragNDropValueObject[] | ClozePart[][]): void {
+              DragNDropValueObject[] | ClozeDocument): void {
     if (this.fontProps && property in this.fontProps) {
       this.fontProps[property] = value as string | number | boolean;
     } else if (this.surfaceProps && property in this.surfaceProps) {
@@ -81,6 +81,24 @@ export abstract class InputElement extends UIElement {
 
 export abstract class CompoundElement extends UIElement {}
 
+export interface ClozeDocument {
+  type: string;
+  content: ClozeDocumentParagraph[]
+}
+
+export interface ClozeDocumentParagraph {
+  type: string;
+  attrs: Record<string, string | number | boolean>;
+  content: ClozeDocumentPart[];
+}
+
+export interface ClozeDocumentPart {
+  type: string;
+  text?: string;
+  marks?: any;
+  attrs?: Record<string, string | number | boolean | InputElement>;
+}
+
 export interface ValueChangeElement {
   id: string;
   values: [InputElementValue, InputElementValue];
@@ -172,9 +190,3 @@ export interface LikertRow {
   text: string;
   columnCount: number;
 }
-
-export type ClozePart = {
-  type: string;
-  value: string | UIElement;
-  style?: string;
-};
diff --git a/projects/common/shared.module.ts b/projects/common/shared.module.ts
index 0c0b9b991..993815e51 100644
--- a/projects/common/shared.module.ts
+++ b/projects/common/shared.module.ts
@@ -49,6 +49,7 @@ import { SpellCorrectComponent } from './ui-elements/spell-correct/spell-correct
 import { DropListSimpleComponent } from './ui-elements/drop-list-simple/drop-list-simple.component';
 import { FrameComponent } from './ui-elements/frame/frame.component';
 import { ToggleButtonComponent } from './ui-elements/toggle-button/toggle-button.component';
+import { MarkPipe } from './ui-elements/cloze/mark.pipe';
 
 @NgModule({
   imports: [
@@ -99,7 +100,8 @@ import { ToggleButtonComponent } from './ui-elements/toggle-button/toggle-button
     SpellCorrectComponent,
     TextFieldSimpleComponent,
     FrameComponent,
-    ToggleButtonComponent
+    ToggleButtonComponent,
+    MarkPipe
   ],
   exports: [
     CommonModule,
@@ -115,7 +117,11 @@ import { ToggleButtonComponent } from './ui-elements/toggle-button/toggle-button
     MatTooltipModule,
     MatDialogModule,
     TranslateModule,
-    SafeResourceHTMLPipe
+    SafeResourceHTMLPipe,
+    ToggleButtonComponent,
+    TextFieldComponent,
+    DropListSimpleComponent,
+    TextFieldSimpleComponent
   ]
 })
 export class SharedModule {
diff --git a/projects/common/ui-elements/cloze/cloze-element.ts b/projects/common/ui-elements/cloze/cloze-element.ts
index 2f4901d8d..426ccdf9c 100644
--- a/projects/common/ui-elements/cloze/cloze-element.ts
+++ b/projects/common/ui-elements/cloze/cloze-element.ts
@@ -1,25 +1,21 @@
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import ToggleButtonExtension from './tiptap-editor-extensions/toggle-button';
+import DropListExtension from './tiptap-editor-extensions/drop-list';
+import TextFieldExtension from './tiptap-editor-extensions/text-field';
 import {
-  ClozePart,
-  CompoundElement,
-  FontElement,
-  FontProperties, InputElement,
+  UIElement, InputElement, CompoundElement,
+  ClozeDocument,
   PositionedElement, PositionProperties,
-  UIElement
+  FontElement, FontProperties
 } from '../../models/uI-element';
 import { initFontElement, initPositionedElement } from '../../util/unit-interface-initializer';
 import { TextFieldSimpleElement } from '../textfield-simple/text-field-simple-element';
-import { TextFieldElement } from '../text-field/text-field-element';
-import { TextAreaElement } from '../text-area/text-area-element';
-import { CheckboxElement } from '../checkbox/checkbox-element';
-import { DropdownElement } from '../dropdown/dropdown-element';
 import { DropListSimpleElement } from '../drop-list-simple/drop-list-simple';
 import { ToggleButtonElement } from '../toggle-button/toggle-button';
 
-// TODO styles like em dont continue after inserted components
-
 export class ClozeElement extends CompoundElement implements PositionedElement, FontElement {
-  text: string = 'Lorem ipsum dolor \\r sdfsdf \\i sdfsdf';
-  parts: ClozePart[][] = [];
+  document: ClozeDocument = { type: 'doc', content: [] };
 
   positionProps: PositionProperties;
   fontProps: FontProperties;
@@ -30,40 +26,91 @@ export class ClozeElement extends CompoundElement implements PositionedElement,
     this.positionProps = initPositionedElement(serializedElement);
     this.fontProps = initFontElement(serializedElement);
 
-    if (serializedElement?.parts) {
-      serializedElement?.parts.forEach((subParts: ClozePart[]) => {
-        subParts.forEach((part: ClozePart) => {
-          if (!['p', 'h1', 'h2', 'h3', 'h4'].includes(part.type)) {
-            part.value = ClozeElement.createElement(part.value as UIElement);
+    if (serializedElement.document) {
+      serializedElement.document.content.forEach((paragraph: any) => {
+        paragraph.content?.forEach((node: any) => {
+          if (['ToggleButton', 'DropList', 'TextField'].includes(node.type)) {
+            node.attrs.model = ClozeElement.createElement(node.attrs.model);
           }
         });
       });
     }
 
+    // text property indicates old unit definition
+    if (serializedElement.text) {
+      this.handleBackwardsCompatibility(serializedElement);
+    }
+
     this.width = serializedElement.width || 450;
     this.height = serializedElement.height || 200;
   }
 
-  static createElement(elementModel: Partial<UIElement>): InputElement {
+  private handleBackwardsCompatibility(serializedElement: Partial<UIElement>): void {
+    const childModels = ClozeElement.parseElementList(serializedElement.parts);
+
+    const textFieldElementList = Object.values(childModels).filter((el: any) => el.type === 'text-field');
+    const dropListElementList = Object.values(childModels).filter((el: any) => el.type === 'drop-list');
+    const radioElementList = Object.values(childModels).filter((el: any) => el.type === 'toggle-button');
+
+    const replacedText = (serializedElement.text as string).replace(/\\i|\\z|\\r/g, match => {
+      switch (match) {
+        case '\\i':
+          return `<app-nodeview-text-field id="${textFieldElementList.shift()?.id}"></app-nodeview-text-field>`;
+          break;
+        case '\\z':
+          return `<app-nodeview-drop-list id="${dropListElementList.shift()?.id}"></app-nodeview-drop-list>`;
+          break;
+        case '\\r':
+          return `<app-nodeview-toggle-button id="${radioElementList.shift()?.id}"></app-nodeview-toggle-button>`;
+          break;
+        default:
+          throw Error('error in match');
+      }
+      return match;
+    });
+
+    if (textFieldElementList.length === 0 ||
+        dropListElementList.length === 0 ||
+        radioElementList.length === 0) {
+      throw Error('Error while reading cloze element!');
+    }
+
+    const editor = new Editor({
+      extensions: [
+        StarterKit,
+        ToggleButtonExtension,
+        DropListExtension,
+        TextFieldExtension
+      ],
+      content: replacedText
+    });
+    this.document = editor.getJSON() as ClozeDocument;
+  }
+
+  private static parseElementList(
+    serializedParts: { type: string; value: string | UIElement; style?: string; }[][]
+  ): InputElement[] {
+    const knownElementTypes = ['text-field', 'drop-list', 'toggle-button'];
+    const newElementList: InputElement[] = [];
+
+    serializedParts.forEach((part: any) => {
+      for (const subPart of part) {
+        if (knownElementTypes.includes(subPart.type)) {
+          newElementList.push(subPart.value);
+        }
+      }
+    });
+    return newElementList;
+  }
+
+  private static createElement(elementModel: Partial<UIElement>): InputElement {
     let newElement: InputElement;
     switch (elementModel.type) {
       case 'text-field':
         newElement = new TextFieldSimpleElement(elementModel);
-        (newElement as TextFieldElement).label = '';
-        break;
-      case 'text-area':
-        newElement = new TextAreaElement(elementModel);
-        break;
-      case 'checkbox':
-        newElement = new CheckboxElement(elementModel);
-        break;
-      case 'dropdown':
-        newElement = new DropdownElement(elementModel);
         break;
       case 'drop-list':
         newElement = new DropListSimpleElement(elementModel);
-        newElement.height = 25; // TODO weg?
-        newElement.width = 100;
         break;
       case 'toggle-button':
         newElement = new ToggleButtonElement(elementModel);
diff --git a/projects/common/ui-elements/cloze/cloze.component.ts b/projects/common/ui-elements/cloze/cloze.component.ts
index 1348395a1..436d1c99f 100644
--- a/projects/common/ui-elements/cloze/cloze.component.ts
+++ b/projects/common/ui-elements/cloze/cloze.component.ts
@@ -1,93 +1,83 @@
 import {
-  Component, EventEmitter, Output, QueryList, ViewChildren
+  Component, EventEmitter, Input, Output, QueryList, ViewChildren
 } from '@angular/core';
 import { ClozeElement } from './cloze-element';
 import { CompoundElementComponent } from '../../directives/compound-element.directive';
-import { InputElement, ClozePart } from '../../models/uI-element';
+import { ClozeDocumentParagraph, ClozeDocumentPart, InputElement } from '../../models/uI-element';
 import { FormElementComponent } from '../../directives/form-element-component.directive';
 
 @Component({
   selector: 'app-cloze',
   template: `
+    <ng-container *ngIf="elementModel.document.content.length == 0">
+      Kein Dokument vorhanden
+    </ng-container>
     <div [class.center-content]="elementModel.positionProps.dynamicPositioning &&
                                  elementModel.positionProps.fixedSize"
          [style.width]="elementModel.positionProps.fixedSize ? elementModel.width + 'px' : '100%'"
          [style.height]="elementModel.positionProps.fixedSize ? elementModel.height + 'px' : 'auto'">
-      <p *ngFor="let paragraph of elementModel.parts; let i = index"
-         [style.line-height.%]="elementModel.fontProps.lineHeight"
-         [style.color]="elementModel.fontProps.fontColor"
-         [style.font-family]="elementModel.fontProps.font"
-         [style.font-size.px]="elementModel.fontProps.fontSize"
-         [style.font-weight]="elementModel.fontProps.bold ? 'bold' : ''"
-         [style.font-style]="elementModel.fontProps.italic ? 'italic' : ''"
-         [style.text-decoration]="elementModel.fontProps.underline ? 'underline' : ''">
-        <ng-container *ngFor="let part of paragraph; let j = index">
-
-          <span *ngIf="part.type === 'p'"
-               [innerHTML]="part.value"
-               [style]="part.style">
-          </span>
-
-          <h1 *ngIf="part.type === 'h1'"
-              [innerHTML]="part.value"
-              [style.display]="'inline'"
-              [style]="part.style">
-          </h1>
-          <h2 *ngIf="part.type === 'h2'"
-              [innerHTML]="part.value"
-              [style.display]="'inline'"
-              [style]="part.style">
-          </h2>
-          <h3 *ngIf="part.type === 'h3'"
-              [innerHTML]="part.value"
-              [style.display]="'inline'"
-              [style]="part.style">
-          </h3>
-          <h4 *ngIf="part.type === 'h4'"
-              [innerHTML]="part.value"
-              [style.display]="'inline'"
-              [style]="part.style">
-          </h4>
-
-          <span (click)="allowClickThrough || selectElement($any(part.value), $event)">
-            <app-dropdown *ngIf="part.type === 'dropdown'" #drowdownComponent
-                          [parentForm]="parentForm"
-                          [style.display]="'inline-block'"
-                          [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
-                          [elementModel]="$any(part.value)"
-                          (elementValueChanged)="elementValueChanged.emit($event)">
-            </app-dropdown>
-            <app-text-field-simple *ngIf="part.type === 'text-field'" #textfieldComponent
-                            [parentForm]="parentForm"
-                            [style.display]="'inline-block'"
-                            [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
-                            [elementModel]="$any(part.value)"
-                            (elementValueChanged)="elementValueChanged.emit($event)">
-            </app-text-field-simple>
-
-            <app-toggle-button *ngIf="part.type === 'toggle-button'" #radioComponent
-                            [parentForm]="parentForm"
-                            [style.display]="'inline-block'"
-                            [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
-                            [elementModel]="$any(part.value)"
-                            (elementValueChanged)="elementValueChanged.emit($event)">
-            </app-toggle-button>
-
-            <div *ngIf="part.type === 'drop-list'"
-                 [style.display]="'inline-block'"
-                 [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
-                 [style.vertical-align]="'middle'"
-                 [style.width.px]="$any(part.value).width"
-                 [style.height.px]="$any(part.value).height">
-              <app-drop-list-simple #droplistComponent
-                             [parentForm]="parentForm"
-                             (elementValueChanged)="elementValueChanged.emit($event)"
-                             [elementModel]="$any(part.value)">
-              </app-drop-list-simple>
-            </div>
-          </span>
-        </ng-container>
-      </p>
+      <ng-container *ngFor="let part of elementModel.document.content">
+        <p *ngIf="part.type === 'paragraph'"
+           [style.line-height.%]="elementModel.fontProps.lineHeight"
+           [style.color]="elementModel.fontProps.fontColor"
+           [style.font-family]="elementModel.fontProps.font"
+           [style.font-size.px]="elementModel.fontProps.fontSize"
+           [style.font-weight]="elementModel.fontProps.bold ? 'bold' : ''"
+           [style.font-style]="elementModel.fontProps.italic ? 'italic' : ''"
+           [style.text-decoration]="elementModel.fontProps.underline ? 'underline' : ''">
+          <ng-container *ngFor="let subPart of part.content">
+            <ng-container *ngIf="subPart.type === 'text'">
+              <span [style.font-weight]="$any((subPart.marks | mark)?.includes('bold')) ? 'bold' : ''"
+                    [style.font-style]="$any((subPart.marks | mark)?.includes('italic')) ? 'italic' : ''"
+                    [style.text-decoration]="$any((subPart.marks | mark)?.includes('underline')) ? 'underline' : ''">
+                {{subPart.text}}
+              </span>
+            </ng-container>
+            <span *ngIf="['ToggleButton', 'DropList', 'TextField'].includes(subPart.type)"
+                  (click)="selectElement($any(subPart.attrs).model, $event)">
+                <app-toggle-button *ngIf="subPart.type === 'ToggleButton'" #radioComponent
+                                   [parentForm]="parentForm"
+                                   [style.display]="'inline-block'"
+                                   [style.vertical-align]="'middle'"
+                                   [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
+                                   [elementModel]="$any(subPart.attrs).model"
+                                   (elementValueChanged)="elementValueChanged.emit($event)">
+                </app-toggle-button>
+                <app-text-field-simple *ngIf="subPart.type === 'TextField'" #textfieldComponent
+                                       [parentForm]="parentForm"
+                                       [style.display]="'inline-block'"
+                                       [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
+                                       [elementModel]="$any(subPart.attrs).model"
+                                       (elementValueChanged)="elementValueChanged.emit($event)">
+                </app-text-field-simple>
+                <app-drop-list-simple *ngIf="subPart.type === 'DropList'" #droplistComponent
+                                      [parentForm]="parentForm"
+                                      [style.display]="'inline-block'"
+                                      [style.vertical-align]="'middle'"
+                                      [style.pointerEvents]="allowClickThrough ? 'auto' : 'none'"
+                                      [elementModel]="$any(subPart.attrs).model"
+                                      (elementValueChanged)="elementValueChanged.emit($event)">
+                </app-drop-list-simple>
+            </span>
+          </ng-container>
+        </p>
+        <h1 *ngIf="part.type === 'heading' && part.attrs.level === 1"
+            [style.display]="'inline'">
+          {{part.content[0].text}}
+        </h1>
+        <h2 *ngIf="part.type === 'heading' && part.attrs.level === 2"
+            [style.display]="'inline'">
+          {{part.content[0].text}}
+        </h2>
+        <h3 *ngIf="part.type === 'heading' && part.attrs.level === 3"
+            [style.display]="'inline'">
+          {{part.content[0].text}}
+        </h3>
+        <h4 *ngIf="part.type === 'heading' && part.attrs.level === 4"
+            [style.display]="'inline'">
+          {{part.content[0].text}}
+        </h4>
+      </ng-container>
     </div>
   `,
   styles: [
@@ -96,25 +86,24 @@ import { FormElementComponent } from '../../directives/form-element-component.di
     ':host ::ng-deep app-text-field .mat-form-field {height: 100%}',
     ':host ::ng-deep app-text-field .mat-form-field-flex {height: 100%}',
     'p {margin: 0}',
+    ':host ::ng-deep p strong {letter-spacing: 0.04em; font-weight: 600;}', // bold less bold
+    ':host ::ng-deep p:empty::after {content: "\\00A0"}', // render empty p
     'p span {font-size: inherit}'
   ]
 })
 export class ClozeComponent extends CompoundElementComponent {
-  elementModel!: ClozeElement;
+  @Input() elementModel!: ClozeElement;
   @Output() elementSelected = new EventEmitter<{ element: ClozeElement, event: MouseEvent }>();
   @ViewChildren('drowdownComponent, textfieldComponent, droplistComponent, radioComponent')
   compoundChildren!: QueryList<FormElementComponent>;
 
   getFormElementModelChildren(): InputElement[] {
-    const uiElements: InputElement[] = [];
-    this.elementModel.parts.forEach((subParts: ClozePart[]) => {
-      subParts.forEach((part: ClozePart) => {
-        if (part.value instanceof InputElement) {
-          uiElements.push(part.value);
-        }
-      });
-    });
-    return uiElements;
+    return this.elementModel.document.content
+      .filter((paragraph: ClozeDocumentParagraph) => paragraph.content) // filter empty paragraphs
+      .map((paragraph: ClozeDocumentParagraph) => paragraph.content // get custom paragraph parts
+        .filter((word: ClozeDocumentPart) => ['TextField', 'DropList', 'ToggleButton'].includes(word.type)))
+      .reduce((accumulator: any[], currentValue: any) => accumulator // put all collected paragraph parts into one list
+        .concat(currentValue.map((node: ClozeDocumentPart) => node.attrs?.model)), []); // model is in node.attrs.model
   }
 
   selectElement(element: ClozeElement, event: MouseEvent): void {
diff --git a/projects/common/ui-elements/cloze/mark.pipe.ts b/projects/common/ui-elements/cloze/mark.pipe.ts
new file mode 100644
index 000000000..d9b24d9e0
--- /dev/null
+++ b/projects/common/ui-elements/cloze/mark.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'mark'
+})
+/* This extracts marks from an item and puts them is a list to be searchable.
+  Only used in cloze component */
+export class MarkPipe implements PipeTransform {
+  transform(items: any[]): any[] {
+    if (!items) {
+      return items;
+    }
+    return items.map(item => item.type);
+  }
+}
diff --git a/projects/common/ui-elements/cloze/tiptap-editor-extensions/drop-list.ts b/projects/common/ui-elements/cloze/tiptap-editor-extensions/drop-list.ts
new file mode 100644
index 000000000..9aea0b7cd
--- /dev/null
+++ b/projects/common/ui-elements/cloze/tiptap-editor-extensions/drop-list.ts
@@ -0,0 +1,25 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+const DropListExtension =
+  Node.create({
+    group: 'inline',
+    inline: true,
+    name: 'DropList',
+
+    addAttributes() {
+      return {
+        id: {
+          default: 'will be generated'
+        }
+      };
+    },
+
+    parseHTML() {
+      return [{ tag: 'app-nodeview-drop-list' }];
+    },
+    renderHTML({ HTMLAttributes }) {
+      return ['app-nodeview-drop-list', mergeAttributes(HTMLAttributes)];
+    }
+  });
+
+export default DropListExtension;
diff --git a/projects/common/ui-elements/cloze/tiptap-editor-extensions/text-field.ts b/projects/common/ui-elements/cloze/tiptap-editor-extensions/text-field.ts
new file mode 100644
index 000000000..251cc0385
--- /dev/null
+++ b/projects/common/ui-elements/cloze/tiptap-editor-extensions/text-field.ts
@@ -0,0 +1,25 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+const TextFieldExtension =
+  Node.create({
+    group: 'inline',
+    inline: true,
+    name: 'TextField',
+
+    addAttributes() {
+      return {
+        id: {
+          default: 'will be generated'
+        }
+      };
+    },
+
+    parseHTML() {
+      return [{ tag: 'app-nodeview-text-field' }];
+    },
+    renderHTML({ HTMLAttributes }) {
+      return ['app-nodeview-text-field', mergeAttributes(HTMLAttributes)];
+    }
+  });
+
+export default TextFieldExtension;
diff --git a/projects/common/ui-elements/cloze/tiptap-editor-extensions/toggle-button.ts b/projects/common/ui-elements/cloze/tiptap-editor-extensions/toggle-button.ts
new file mode 100644
index 000000000..1cf4d2e8f
--- /dev/null
+++ b/projects/common/ui-elements/cloze/tiptap-editor-extensions/toggle-button.ts
@@ -0,0 +1,25 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+const ToggleButtonExtension =
+  Node.create({
+    group: 'inline',
+    inline: true,
+    name: 'ToggleButton',
+
+    addAttributes() {
+      return {
+        id: {
+          default: 'will be generated'
+        }
+      };
+    },
+
+    parseHTML() {
+      return [{ tag: 'app-nodeview-toggle-button' }];
+    },
+    renderHTML({ HTMLAttributes }) {
+      return ['app-nodeview-toggle-button', mergeAttributes(HTMLAttributes)];
+    }
+  });
+
+export default ToggleButtonExtension;
diff --git a/projects/editor/src/app/services/unit.service.ts b/projects/editor/src/app/services/unit.service.ts
index 545bbd872..3af112e55 100644
--- a/projects/editor/src/app/services/unit.service.ts
+++ b/projects/editor/src/app/services/unit.service.ts
@@ -245,7 +245,7 @@ export class UnitService {
   }
 
   updateElementProperty(elements: UIElement[], property: string,
-                        value: InputElementValue | LikertColumn[] | LikertRow[] |
+                        value: InputElementValue | LikertColumn[] | LikertRow[] | ClozeDocument |
                         DragNDropValueObject[] | null): boolean {
     // console.log('updateElementProperty', elements, property, value);
     for (const element of elements) {
@@ -257,8 +257,11 @@ export class UnitService {
         this.idService.removeId(element.id);
         this.idService.addID(value as string);
         element.setProperty('id', value);
-      } else if (property === 'text' && element.type === 'cloze') {
-        element.setProperty('parts', ClozeParser.createClozeParts(value as string, this.idService));
+      } else if (property === 'document') {
+        element.setProperty('document', ClozeParser.setMissingIDs(
+          value as ClozeDocument,
+          this.idService
+        ));
       } else {
         element.setProperty(property, value);
       }
@@ -427,12 +430,15 @@ export class UnitService {
         });
         break;
       case 'cloze':
-        this.dialogService.showClozeTextEditDialog((element as TextElement).text).subscribe((result: string) => {
+        this.dialogService.showClozeTextEditDialog(
+          element.document,
+          (element as ClozeElement).fontProps.fontSize as number
+        ).subscribe((result: string) => {
           if (result) {
             // TODO add proper sanitization
             this.updateElementProperty(
               [element],
-              'text',
+              'document',
               (this.sanitizer.bypassSecurityTrustHtml(result) as any).changingThisBreaksApplicationSecurity as string
             );
           }
diff --git a/projects/editor/src/app/util/cloze-parser.ts b/projects/editor/src/app/util/cloze-parser.ts
index 9a7d75cf5..7805dcd3e 100644
--- a/projects/editor/src/app/util/cloze-parser.ts
+++ b/projects/editor/src/app/util/cloze-parser.ts
@@ -1,109 +1,32 @@
-import { InputElement, UIElement, ClozePart } from '../../../../common/models/uI-element';
+import { ClozeDocument, InputElement, UIElement } from '../../../../common/models/uI-element';
+import { IdService } from '../services/id.service';
 import { TextFieldSimpleElement } from '../../../../common/ui-elements/textfield-simple/text-field-simple-element';
-import { TextFieldElement } from '../../../../common/ui-elements/text-field/text-field-element';
-import { TextAreaElement } from '../../../../common/ui-elements/text-area/text-area-element';
-import { CheckboxElement } from '../../../../common/ui-elements/checkbox/checkbox-element';
-import { DropdownElement } from '../../../../common/ui-elements/dropdown/dropdown-element';
 import { DropListSimpleElement } from '../../../../common/ui-elements/drop-list-simple/drop-list-simple';
 import { ToggleButtonElement } from '../../../../common/ui-elements/toggle-button/toggle-button';
-import { IdService } from '../services/id.service';
 
 export abstract class ClozeParser {
-  static createClozeParts(htmlText: string, idService: IdService): ClozePart[][] {
-    const elementList = ClozeParser.createElementList(htmlText);
-
-    const parts: ClozePart[][] = [];
-    elementList.forEach((element: HTMLParagraphElement | HTMLHeadingElement, i: number) => {
-      ClozeParser.parseParagraphs(element, i, parts, idService);
-    });
-    return parts;
-  }
-
-  private static createElementList(htmlText: string): (HTMLParagraphElement | HTMLHeadingElement)[] {
-    const el = document.createElement('html');
-    el.innerHTML = htmlText;
-    return Array.from(el.children[1].children) as (HTMLParagraphElement | HTMLHeadingElement)[];
-  }
-
-  // TODO refactor passed parts, so the Part is returned instead if manipulating the param array
-  private static parseParagraphs(
-    element: HTMLParagraphElement | HTMLHeadingElement, partIndex: number, parts: ClozePart[][], idService: IdService
-  ): ClozePart[][] {
-    parts[partIndex] = [];
-    let [nextSpecialElementIndex, nextElementType] = ClozeParser.getNextElementMarker(element.innerHTML);
-    let indexOffset = 0;
-
-    while (nextSpecialElementIndex !== -1) {
-      nextSpecialElementIndex += indexOffset;
-      parts[partIndex].push({
-        type: element.localName,
-        value: element.innerHTML.substring(indexOffset, nextSpecialElementIndex),
-        style: element.style.cssText
+  static setMissingIDs(clozeJSON: ClozeDocument, idService: IdService): ClozeDocument {
+    clozeJSON.content.forEach((paragraph: any) => {
+      paragraph.content?.forEach((node: any) => {
+        if (['ToggleButton', 'DropList', 'TextField'].includes(node.type) &&
+            node.attrs.model.id === 'id_placeholder') {
+          // create element anew because the TextEditor can't create multiple element instances
+          node.attrs.model = ClozeParser.createElement(node.attrs.model);
+          node.attrs.model.id = idService.getNewID(node.attrs.model.type);
+        }
       });
-
-      const newElement = ClozeParser.createElement({ type: nextElementType } as UIElement, idService);
-      parts[partIndex].push({ type: nextElementType, value: newElement });
-
-      indexOffset = nextSpecialElementIndex + 2; // + 2 to get rid of the marker, i.e. '\b'
-      [nextSpecialElementIndex, nextElementType] =
-        ClozeParser.getNextElementMarker(element.innerHTML.substring(indexOffset));
-    }
-    parts[partIndex].push({
-      type: element.localName,
-      value: element.innerHTML.substring(indexOffset),
-      style: element.style.cssText
     });
-    return parts;
+    return clozeJSON;
   }
 
-  private static getNextElementMarker(p: string): [number, string] {
-    const x = [];
-    if (p.indexOf('\\d') > 0) {
-      x.push(p.indexOf('\\d'));
-    }
-    if (p.indexOf('\\i') > 0) {
-      x.push(p.indexOf('\\i'));
-    }
-    if (p.indexOf('\\z') > 0) {
-      x.push(p.indexOf('\\z'));
-    }
-    if (p.indexOf('\\r') > 0) {
-      x.push(p.indexOf('\\r'));
-    }
-
-    const y = Math.min(...x);
-    let nextElementType = '';
-    switch (p[y + 1]) {
-      case 'd': nextElementType = 'dropdown'; break;
-      case 'i': nextElementType = 'text-field'; break;
-      case 'z': nextElementType = 'drop-list'; break;
-      case 'r': nextElementType = 'toggle-button'; break;
-      default: return [-1, 'unknown'];
-    }
-    return [y, nextElementType];
-  }
-
-  private static createElement(elementModel: Partial<UIElement>, idService: IdService): InputElement {
+  private static createElement(elementModel: Partial<UIElement>): InputElement {
     let newElement: InputElement;
-    elementModel.id = idService.getNewID(elementModel.type as string);
     switch (elementModel.type) {
       case 'text-field':
         newElement = new TextFieldSimpleElement(elementModel);
-        (newElement as TextFieldElement).label = '';
-        break;
-      case 'text-area':
-        newElement = new TextAreaElement(elementModel);
-        break;
-      case 'checkbox':
-        newElement = new CheckboxElement(elementModel);
-        break;
-      case 'dropdown':
-        newElement = new DropdownElement(elementModel);
         break;
       case 'drop-list':
         newElement = new DropListSimpleElement(elementModel);
-        newElement.height = 25; // TODO weg?
-        newElement.width = 100;
         break;
       case 'toggle-button':
         newElement = new ToggleButtonElement(elementModel);
-- 
GitLab