diff --git a/projects/common/models/uI-element.ts b/projects/common/models/uI-element.ts index 83873d87d7fb6592f22282f15e7f2c9cc2fec100..3b1e920165552e6ce96086a1f2ac9385c768247f 100644 --- a/projects/common/models/uI-element.ts +++ b/projects/common/models/uI-element.ts @@ -24,6 +24,9 @@ export abstract class UIElement { playerProps?: PlayerProperties; protected constructor(serializedElement: Partial<UIElement>) { + if (!serializedElement.type) { + throw Error('No element type given!'); + } Object.assign(this, serializedElement); } @@ -46,7 +49,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) { @@ -62,7 +65,7 @@ export abstract class UIElement { } export abstract class InputElement extends UIElement { - label: string; + label?: string; value: InputElementValue; required: boolean; requiredWarnMessage: string; @@ -81,6 +84,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 +193,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 d86aefabc67ed92b2277b7f5ba96bd8df80e4beb..b7234be929b968d3cb9d77b09cdb803cb64cbc62 100644 --- a/projects/common/shared.module.ts +++ b/projects/common/shared.module.ts @@ -50,6 +50,7 @@ import { DropListSimpleComponent } from './ui-elements/drop-list-simple/drop-lis import { FrameComponent } from './ui-elements/frame/frame.component'; import { ToggleButtonComponent } from './ui-elements/toggle-button/toggle-button.component'; import { MarkingBarComponent } from './components/marking-bar/marking-bar.component'; +import { MarkPipe } from './ui-elements/cloze/mark.pipe'; @NgModule({ imports: [ @@ -101,7 +102,8 @@ import { MarkingBarComponent } from './components/marking-bar/marking-bar.compon TextFieldSimpleComponent, FrameComponent, ToggleButtonComponent, - MarkingBarComponent + MarkingBarComponent, + MarkPipe ], exports: [ CommonModule, @@ -118,7 +120,11 @@ import { MarkingBarComponent } from './components/marking-bar/marking-bar.compon MatDialogModule, TranslateModule, SafeResourceHTMLPipe, - MarkingBarComponent + MarkingBarComponent, + 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 2f4901d8d8e0f37e96a1512c4cc9ea5c83d88bf1..426ccdf9c726fcdd175ca49af7555be622545fe8 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 1348395a1db27ca5629c01a8d80f059150fe7ab2..436d1c99f3b7db287de30d8582d67e8247e98d0a 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 0000000000000000000000000000000000000000..d9b24d9e0a645f01f38bb62826c37b08f321b05b --- /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 0000000000000000000000000000000000000000..9aea0b7cd50a86e96df6919f7d195af581cf6d13 --- /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 0000000000000000000000000000000000000000..251cc0385f442deb58dac267993001cc80929933 --- /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 0000000000000000000000000000000000000000..1cf4d2e8f72ea9a0a374a549f01624e114898040 --- /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/common/ui-elements/drop-list-simple/drop-list-simple.component.ts b/projects/common/ui-elements/drop-list-simple/drop-list-simple.component.ts index c73ae3e6a6a9ffd2cc82fa6eeeb08173fe21c74e..80baa100e1748a67ae826a50482f201598887e98 100644 --- a/projects/common/ui-elements/drop-list-simple/drop-list-simple.component.ts +++ b/projects/common/ui-elements/drop-list-simple/drop-list-simple.component.ts @@ -14,6 +14,8 @@ import { DragNDropValueObject } from '../../models/uI-element'; <!-- Border width is a workaround to enable/disable the Material cdk-drop-list-receiving--> <!-- class style.--> <div class="list" + [style.height.px]="elementModel.height" + [style.width.px]="elementModel.width" [class.dropList-highlight]="elementModel.highlightReceivingDropList" [style.border-color]="elementModel.highlightReceivingDropListColor" [style.border-width.px]="elementModel.highlightReceivingDropList ? 2 : 0" diff --git a/projects/common/ui-elements/drop-list-simple/drop-list-simple.ts b/projects/common/ui-elements/drop-list-simple/drop-list-simple.ts index 41e4cd41139d6f87729a5a449d4eaf72d57b48c5..c04b847eae99e3678090664c45b368c67aeac788 100644 --- a/projects/common/ui-elements/drop-list-simple/drop-list-simple.ts +++ b/projects/common/ui-elements/drop-list-simple/drop-list-simple.ts @@ -2,12 +2,11 @@ import { FontElement, FontProperties, InputElement, - PositionedElement, PositionProperties, SurfaceElement, SurfaceProperties, UIElement } from '../../models/uI-element'; -import { initFontElement, initPositionedElement, initSurfaceElement } from '../../util/unit-interface-initializer'; +import { initFontElement, initSurfaceElement } from '../../util/unit-interface-initializer'; export class DropListSimpleElement extends InputElement implements FontElement, SurfaceElement { connectedTo: string[] = []; @@ -24,6 +23,8 @@ export class DropListSimpleElement extends InputElement implements FontElement, this.fontProps = initFontElement(serializedElement); this.surfaceProps = initSurfaceElement(serializedElement); + delete this.label; + this.value = serializedElement.value as string[] || []; this.height = serializedElement.height || 100; this.surfaceProps.backgroundColor = diff --git a/projects/common/ui-elements/textfield-simple/text-field-simple-element.ts b/projects/common/ui-elements/textfield-simple/text-field-simple-element.ts index b06fb78999eae6ef87b0bc7a76a7ce684e2da84b..4f94232578596fb224273206377c557a68d62ac5 100644 --- a/projects/common/ui-elements/textfield-simple/text-field-simple-element.ts +++ b/projects/common/ui-elements/textfield-simple/text-field-simple-element.ts @@ -18,6 +18,8 @@ export class TextFieldSimpleElement extends InputElement implements FontElement, this.fontProps = initFontElement(serializedElement); this.surfaceProps = initSurfaceElement(serializedElement); + delete this.label; + this.height = serializedElement.height || 25; } } diff --git a/projects/common/ui-elements/toggle-button/toggle-button.component.ts b/projects/common/ui-elements/toggle-button/toggle-button.component.ts index 2ca71171d6eda9f0fa4e287e5a9062c4d2181bb1..cdb8f6f59ccf1ce75174756810bd67909e23ba79 100644 --- a/projects/common/ui-elements/toggle-button/toggle-button.component.ts +++ b/projects/common/ui-elements/toggle-button/toggle-button.component.ts @@ -8,7 +8,9 @@ import { FormElementComponent } from '../../directives/form-element-component.di <div class="mat-form-field"> <mat-button-toggle-group [formControl]="elementFormControl" [value]="elementModel.value" - [style.height.px]="elementModel.height"> + [style.height.px]="elementModel.height" + [style.width]="elementModel.dynamicWidth ? 'unset' : elementModel.width+'px'" + [vertical]="elementModel.verticalOrientation"> <mat-button-toggle *ngFor="let option of elementModel.options; let i = index" [value]="i" [ngClass]="{ 'strike' : elementModel.strikeOtherOptions && @@ -25,6 +27,7 @@ import { FormElementComponent } from '../../directives/form-element-component.di elementModel.selectionColor : elementModel.surfaceProps.backgroundColor" [style.line-height.%]="elementModel.fontProps.lineHeight"> + <!--Background color does not show in editor--> {{option}} </mat-button-toggle> </mat-button-toggle-group> @@ -32,8 +35,9 @@ import { FormElementComponent } from '../../directives/form-element-component.di `, styles: [ 'mat-button-toggle-group {min-width: 70px; min-height: 20px}', + ':host ::ng-deep mat-button-toggle {width: 100%; height: 100%}', ':host ::ng-deep .mat-button-toggle-button {height: 100%}', - ':host ::ng-deep .mat-button-toggle-label-content {height: 100%; line-height: unset}', + ':host ::ng-deep .mat-button-toggle-label-content {line-height: unset}', ':host ::ng-deep .strike .mat-button-toggle-label-content {text-decoration: line-through}' ] }) diff --git a/projects/common/ui-elements/toggle-button/toggle-button.ts b/projects/common/ui-elements/toggle-button/toggle-button.ts index e950a0cdd3e216bfe60dc300e675f08cebe0db09..b90c98a29744c57bd5fed4a3a83c06bf50bf8f04 100644 --- a/projects/common/ui-elements/toggle-button/toggle-button.ts +++ b/projects/common/ui-elements/toggle-button/toggle-button.ts @@ -8,9 +8,11 @@ import { import { initFontElement, initSurfaceElement } from '../../util/unit-interface-initializer'; export class ToggleButtonElement extends InputElement implements FontElement, SurfaceElement { - options: string[] = ['abc', 'def']; + options: string[] = ['A', 'B']; strikeOtherOptions: boolean = false; selectionColor: string = 'lightgreen'; + verticalOrientation = false; + dynamicWidth: boolean = true; fontProps: FontProperties; surfaceProps: SurfaceProperties; @@ -21,6 +23,8 @@ export class ToggleButtonElement extends InputElement implements FontElement, Su this.fontProps = initFontElement(serializedElement); this.surfaceProps = initSurfaceElement(serializedElement); + delete this.label; + this.height = serializedElement.height as number || 25; this.surfaceProps.backgroundColor = serializedElement.surfaceProps?.backgroundColor as string || diff --git a/projects/editor/src/app/app.module.ts b/projects/editor/src/app/app.module.ts index 60aea9f31506606ce20e4748ed4de5c7ad66e1f8..9e914deb1ab467bb5bb0b47ec393bf988aa08dfb 100644 --- a/projects/editor/src/app/app.module.ts +++ b/projects/editor/src/app/app.module.ts @@ -53,6 +53,10 @@ import { ElementModelPropertiesComponent } from './components/unit-view/page-view/properties-panel/element-model-properties.component'; import { DropListOptionEditDialogComponent } from './components/dialogs/drop-list-option-edit-dialog.component'; +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'; + @NgModule({ declarations: [ AppComponent, @@ -68,6 +72,9 @@ import { DropListOptionEditDialogComponent } from './components/dialogs/drop-lis SectionStaticComponent, SectionDynamicComponent, RichTextEditorComponent, + ToggleButtonNodeviewComponent, + TextFieldNodeviewComponent, + DropListNodeviewComponent, ElementStylePropertiesComponent, ElementSizingPropertiesComponent, ConfirmationDialogComponent, diff --git a/projects/editor/src/app/components/dialogs/rich-text-edit-dialog.component.ts b/projects/editor/src/app/components/dialogs/rich-text-edit-dialog.component.ts index 65f19fb8ffa7b7997fa6c2dfabed2e50ba686ce1..366cd8390e3ac81237936e65ffd8a62bae580ce8 100644 --- a/projects/editor/src/app/components/dialogs/rich-text-edit-dialog.component.ts +++ b/projects/editor/src/app/components/dialogs/rich-text-edit-dialog.component.ts @@ -1,24 +1,25 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { ClozeDocument } from '../../../../../common/models/uI-element'; @Component({ selector: 'app-rich-text-edit-dialog', template: ` <mat-dialog-content> - <app-rich-text-editor [(text)]="data.text" - [showCloseElements]="data.showCloseElements" + <app-rich-text-editor [(content)]="data.content" + [clozeMode]="data.clozeMode" [defaultFontSize]="data.defaultFontSize"> </app-rich-text-editor> </mat-dialog-content> <mat-dialog-actions> - <button mat-button [mat-dialog-close]="data.text">{{'save' | translate }}</button> + <button mat-button [mat-dialog-close]="data.content">{{'save' | translate }}</button> <button mat-button mat-dialog-close>{{'cancel' | translate }}</button> </mat-dialog-actions> - ` + ` }) export class RichTextEditDialogComponent { constructor(@Inject(MAT_DIALOG_DATA) public data: { - text: string, + content: string | Record<string, any>, defaultFontSize: number, - showCloseElements?: boolean }) { } + clozeMode: boolean }) { } } diff --git a/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-model-properties.component.html b/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-model-properties.component.html index 9082ba1648fc57be9aad39ee699e578a6cfb0cbc..8b89dd85724b6a9a0cb1da42857e4967c4935706 100644 --- a/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-model-properties.component.html +++ b/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-model-properties.component.html @@ -19,6 +19,12 @@ </div> </ng-container> + <ng-container *ngIf="combinedProperties.document"> + <button (click)="unitService.showDefaultEditDialog(selectedElements[0])"> + <mat-icon>build_circle</mat-icon> + </button> + </ng-container> + <ng-container *ngIf="combinedProperties.playerProps"> <button (click)="unitService.showDefaultEditDialog(selectedElements[0])"> <mat-icon>build_circle</mat-icon> @@ -517,6 +523,12 @@ </mat-select> </mat-form-field> + <mat-checkbox *ngIf="combinedProperties.verticalOrientation !== undefined" + [checked]="$any(combinedProperties.verticalOrientation)" + (change)="updateModel.emit({ property: 'verticalOrientation', value: $event.checked })"> + {{'propertiesPanel.verticalOrientation' | translate }} + </mat-checkbox> + <mat-checkbox *ngIf="combinedProperties.onlyOneItem !== undefined" [checked]="$any(combinedProperties.onlyOneItem)" (change)="updateModel.emit({ property: 'onlyOneItem', value: $event.checked })"> diff --git a/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-sizing-properties.component.ts b/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-sizing-properties.component.ts index c4dd9d9d324b865772b7adc085a5d7a3748e2425..4b78ca844fbd214cf3bcd5b05cd3995383c03c1b 100644 --- a/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-sizing-properties.component.ts +++ b/projects/editor/src/app/components/unit-view/page-view/properties-panel/element-sizing-properties.component.ts @@ -9,8 +9,15 @@ import { PositionedElement, UIElement } from '../../../../../../../common/models selector: 'app-element-sizing-properties', template: ` <div fxLayout="column"> + <mat-checkbox *ngIf="combinedProperties.dynamicWidth !== undefined" + [checked]="$any(combinedProperties.dynamicWidth)" + (change)="updateModel.emit({ property: 'dynamicWidth', value: $event.checked })"> + {{'propertiesPanel.dynamicWidth' | translate }} + </mat-checkbox> + <ng-container *ngIf="!combinedProperties.dynamicPositioning; else elseBlock"> - <mat-form-field appearance="fill"> + <mat-form-field *ngIf="combinedProperties.dynamicWidth === undefined || + !combinedProperties.dynamicWidth" appearance="fill"> <mat-label>{{'propertiesPanel.width' | translate }}</mat-label> <input matInput type="number" #width="ngModel" min="0" [ngModel]="combinedProperties.width" diff --git a/projects/editor/src/app/services/dialog.service.ts b/projects/editor/src/app/services/dialog.service.ts index eb0e2cdfd9da8a9f580cb1fcdaba98760c6e3e52..6ad291ec27030e8d09d86213927ccc802855664b 100644 --- a/projects/editor/src/app/services/dialog.service.ts +++ b/projects/editor/src/app/services/dialog.service.ts @@ -10,6 +10,7 @@ import { PlayerEditDialogComponent } from '../components/dialogs/player-edit-dia import { LikertColumnEditDialogComponent } from '../components/dialogs/likert-column-edit-dialog.component'; import { LikertRowEditDialogComponent } from '../components/dialogs/likert-row-edit-dialog.component'; import { + ClozeDocument, DragNDropValueObject, LikertColumn, PlayerProperties } from '../../../../common/models/uI-element'; import { DropListOptionEditDialogComponent } from '../components/dialogs/drop-list-option-edit-dialog.component'; @@ -50,15 +51,15 @@ export class DialogService { showRichTextEditDialog(text: string, defaultFontSize: number): Observable<string> { const dialogRef = this.dialog.open(RichTextEditDialogComponent, { - data: { text, defaultFontSize }, + data: { content: text, defaultFontSize }, autoFocus: false }); return dialogRef.afterClosed(); } - showClozeTextEditDialog(text: string): Observable<string> { + showClozeTextEditDialog(document: ClozeDocument, defaultFontSize: number): Observable<string> { const dialogRef = this.dialog.open(RichTextEditDialogComponent, { - data: { text, showCloseElements: true }, + data: { content: document, defaultFontSize, clozeMode: true }, autoFocus: false }); return dialogRef.afterClosed(); diff --git a/projects/editor/src/app/services/unit.service.ts b/projects/editor/src/app/services/unit.service.ts index ce26bfb892cde3543556efbd960ed72093e2edda..7648242f51b1d0c5409101d0eefa282601197332 100644 --- a/projects/editor/src/app/services/unit.service.ts +++ b/projects/editor/src/app/services/unit.service.ts @@ -15,7 +15,7 @@ import { InputElement, InputElementValue, LikertColumn, LikertRow, PlayerElement, - PlayerProperties, PositionedElement, + PlayerProperties, PositionedElement, ClozeDocument, UIElement, UIElementType } from '../../../../common/models/uI-element'; @@ -26,6 +26,7 @@ import { SelectionService } from './selection.service'; import { ElementFactory } from '../../../../common/util/element.factory'; import { ClozeParser } from '../util/cloze-parser'; import { Copy } from '../../../../common/util/copy'; +import { ClozeElement } from '../../../../common/ui-elements/cloze/cloze-element'; @Injectable({ providedIn: 'root' @@ -246,7 +247,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) { @@ -258,9 +259,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)); - element.setProperty('text', value); + } else if (property === 'document') { + element.setProperty('document', ClozeParser.setMissingIDs( + value as ClozeDocument, + this.idService + )); } else { element.setProperty(property, Copy.getCopy(value)); } @@ -408,7 +411,7 @@ export class UnitService { case 'dropdown': case 'checkbox': case 'radio': - this.dialogService.showTextEditDialog((element as InputElement).label).subscribe((result: string) => { + this.dialogService.showTextEditDialog(element.label).subscribe((result: string) => { if (result) { this.updateElementProperty([element], 'label', result); } @@ -429,12 +432,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/text-editor/angular-node-views/drop-list-component-extension.ts b/projects/editor/src/app/text-editor/angular-node-views/drop-list-component-extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..4892497b8b9f71d6ad2e88c3d997c855d4014c61 --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/drop-list-component-extension.ts @@ -0,0 +1,33 @@ +import { Injector } from '@angular/core'; +import { Node, mergeAttributes } from '@tiptap/core'; +import { AngularNodeViewRenderer } from 'ngx-tiptap'; +import { DropListNodeviewComponent } from './drop-list-nodeview.component'; +import { DropListSimpleElement } from '../../../../../common/ui-elements/drop-list-simple/drop-list-simple'; + +const DropListComponentExtension = (injector: Injector): Node => { + return Node.create({ + group: 'inline', + inline: true, + name: 'DropList', + + addAttributes() { + return { + model: { + default: new DropListSimpleElement({ type: 'drop-list', height: 25, width: 100 }) + } + }; + }, + + parseHTML() { + return [{ tag: 'app-nodeview-drop-list' }]; + }, + renderHTML({ HTMLAttributes }) { + return ['app-nodeview-drop-list', mergeAttributes(HTMLAttributes)]; + }, + addNodeView() { + return AngularNodeViewRenderer(DropListNodeviewComponent, { injector }); + } + }); +}; + +export default DropListComponentExtension; diff --git a/projects/editor/src/app/text-editor/angular-node-views/drop-list-nodeview.component.ts b/projects/editor/src/app/text-editor/angular-node-views/drop-list-nodeview.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f510c1f665eaf3b90f97989dc5a6baf2890f4f7a --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/drop-list-nodeview.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { AngularNodeViewComponent } from 'ngx-tiptap'; +import { DropListSimpleElement } from '../../../../../common/ui-elements/drop-list-simple/drop-list-simple'; + +@Component({ + selector: 'app-nodeview-drop-list', + template: ` + <div [style.display]="'inline-block'" + [style.vertical-align]="'middle'" + [style.width.px]="model.width" + [style.height.px]="model.height"> + <app-drop-list-simple [elementModel]="node.attrs.model" + [matTooltip]="'ID: ' + node.attrs.model.id"> + </app-drop-list-simple> + </div> + ` +}) +export class DropListNodeviewComponent extends AngularNodeViewComponent { + model: DropListSimpleElement = new DropListSimpleElement({ + type: 'drop-list', + height: 25, + width: 100 + }); +} diff --git a/projects/editor/src/app/text-editor/angular-node-views/text-field-component-extension.ts b/projects/editor/src/app/text-editor/angular-node-views/text-field-component-extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..26104be7ab0a9b7b1fb7805ceccf8ddfd8a68a85 --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/text-field-component-extension.ts @@ -0,0 +1,33 @@ +import { Injector } from '@angular/core'; +import { Node, mergeAttributes } from '@tiptap/core'; +import { AngularNodeViewRenderer } from 'ngx-tiptap'; +import { TextFieldNodeviewComponent } from './text-field-nodeview.component'; +import { TextFieldSimpleElement } from '../../../../../common/ui-elements/textfield-simple/text-field-simple-element'; + +const TextFieldComponentExtension = (injector: Injector): Node => { + return Node.create({ + group: 'inline', + inline: true, + name: 'TextField', + + addAttributes() { + return { + model: { + default: new TextFieldSimpleElement({ type: 'text-field' }) + } + }; + }, + + parseHTML() { + return [{ tag: 'app-nodeview-text-field' }]; + }, + renderHTML({ HTMLAttributes }) { + return ['app-nodeview-text-field', mergeAttributes(HTMLAttributes)]; + }, + addNodeView() { + return AngularNodeViewRenderer(TextFieldNodeviewComponent, { injector }); + } + }); +}; + +export default TextFieldComponentExtension; diff --git a/projects/editor/src/app/text-editor/angular-node-views/text-field-nodeview.component.ts b/projects/editor/src/app/text-editor/angular-node-views/text-field-nodeview.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8189794c3ce1861db5e2ea8a08b4ab47637f81ae --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/text-field-nodeview.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { AngularNodeViewComponent } from 'ngx-tiptap'; + +@Component({ + selector: 'app-nodeview-text-field', + template: ` + <app-text-field-simple [style.display]="'inline-block'" + [elementModel]="node.attrs.model" + [matTooltip]="'ID: ' + node.attrs.model.id"> + </app-text-field-simple> + ` +}) +export class TextFieldNodeviewComponent extends AngularNodeViewComponent { } diff --git a/projects/editor/src/app/text-editor/angular-node-views/toggle-button-component-extension.ts b/projects/editor/src/app/text-editor/angular-node-views/toggle-button-component-extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0821f9d02c536171cac756aa7937b72bb4ed86d --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/toggle-button-component-extension.ts @@ -0,0 +1,35 @@ +import { Injector } from '@angular/core'; +import { Node, mergeAttributes } from '@tiptap/core'; +import { AngularNodeViewRenderer } from 'ngx-tiptap'; + +import { ToggleButtonNodeviewComponent } from './toggle-button-nodeview.component'; +import { DropListSimpleElement } from '../../../../../common/ui-elements/drop-list-simple/drop-list-simple'; +import { ToggleButtonElement } from '../../../../../common/ui-elements/toggle-button/toggle-button'; + +const ToggleButtonComponentExtension = (injector: Injector): Node => { + return Node.create({ + group: 'inline', + inline: true, + name: 'ToggleButton', + + addAttributes() { + return { + model: { + default: new ToggleButtonElement({ type: 'toggle-button' }) + } + }; + }, + + parseHTML() { + return [{ tag: 'app-nodeview-toggle-button' }]; + }, + renderHTML({ HTMLAttributes }) { + return ['app-nodeview-toggle-button', mergeAttributes(HTMLAttributes)]; + }, + addNodeView() { + return AngularNodeViewRenderer(ToggleButtonNodeviewComponent, { injector }); + } + }); +}; + +export default ToggleButtonComponentExtension; diff --git a/projects/editor/src/app/text-editor/angular-node-views/toggle-button-nodeview.component.ts b/projects/editor/src/app/text-editor/angular-node-views/toggle-button-nodeview.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8004c05340237d2d2aa51fba0fd19850c6f6f72b --- /dev/null +++ b/projects/editor/src/app/text-editor/angular-node-views/toggle-button-nodeview.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { AngularNodeViewComponent } from 'ngx-tiptap'; + +@Component({ + selector: 'app-nodeview-toggle-button', + template: ` + <app-toggle-button [style.display]="'inline-block'" + [elementModel]="node.attrs.model" + [matTooltip]="'ID: ' + node.attrs.model.id"> + </app-toggle-button> + ` +}) +export class ToggleButtonNodeviewComponent extends AngularNodeViewComponent { } diff --git a/projects/editor/src/app/text-editor/bulletList-extension.ts b/projects/editor/src/app/text-editor/extensions/bullet-list.ts similarity index 94% rename from projects/editor/src/app/text-editor/bulletList-extension.ts rename to projects/editor/src/app/text-editor/extensions/bullet-list.ts index cb37f6fdf61e887ddef7b45a9a8224d6ee9811f0..54cd0538cf8d86949e636e174faf89b34d3bad1e 100644 --- a/projects/editor/src/app/text-editor/bulletList-extension.ts +++ b/projects/editor/src/app/text-editor/extensions/bullet-list.ts @@ -10,7 +10,9 @@ declare module '@tiptap/core' { } } -export const bulletListExtension = BulletList.extend({ +export const BulletListExtension = BulletList.extend({ + name: 'BulletListExtension', + addAttributes() { return { listStyle: { diff --git a/projects/editor/src/app/text-editor/font-size-extension.ts b/projects/editor/src/app/text-editor/extensions/font-size.ts similarity index 88% rename from projects/editor/src/app/text-editor/font-size-extension.ts rename to projects/editor/src/app/text-editor/extensions/font-size.ts index 94bcdaeb73e5c30ae4c9a9ca65102fefbaaf7516..098ced8b2b7a374c5fc8c14dfaedf7530255f91a 100644 --- a/projects/editor/src/app/text-editor/font-size-extension.ts +++ b/projects/editor/src/app/text-editor/extensions/font-size.ts @@ -9,7 +9,9 @@ declare module '@tiptap/core' { } } -export const fontSizeExtension = TextStyle.extend({ +export const FontSizeExtension = TextStyle.extend({ + name: 'FontSizeExtension', + addAttributes() { return { fontSize: { diff --git a/projects/editor/src/app/text-editor/hanging-indent.ts b/projects/editor/src/app/text-editor/extensions/hanging-indent.ts similarity index 97% rename from projects/editor/src/app/text-editor/hanging-indent.ts rename to projects/editor/src/app/text-editor/extensions/hanging-indent.ts index 428922d58a913b33b331647677af53a8c6e97698..7442817b58266280d3a3abfc4150bd69b6f7ca01 100644 --- a/projects/editor/src/app/text-editor/hanging-indent.ts +++ b/projects/editor/src/app/text-editor/extensions/hanging-indent.ts @@ -13,8 +13,10 @@ declare module '@tiptap/core' { export const HangingIndent = Extension.create({ name: 'hangingIndent', - defaultOptions: { - types: ['paragraph'] + addOptions() { + return { + types: ['paragraph'] + }; }, addGlobalAttributes() { diff --git a/projects/editor/src/app/text-editor/indent.ts b/projects/editor/src/app/text-editor/extensions/indent.ts similarity index 96% rename from projects/editor/src/app/text-editor/indent.ts rename to projects/editor/src/app/text-editor/extensions/indent.ts index 6d2e196766a32a5a809837168553e8c10f5db539..7db3b07dbfa7873626154ae432a58918bf11ec13 100644 --- a/projects/editor/src/app/text-editor/indent.ts +++ b/projects/editor/src/app/text-editor/extensions/indent.ts @@ -19,10 +19,12 @@ declare module '@tiptap/core' { export const Indent = Extension.create<IndentOptions>({ name: 'indent', - defaultOptions: { - types: ['listItem', 'paragraph'], - minLevel: 0, - maxLevel: 8 + addOptions() { + return { + types: ['listItem', 'paragraph'], + minLevel: 0, + maxLevel: 8 + }; }, addGlobalAttributes() { diff --git a/projects/editor/src/app/text-editor/orderedList-extension.ts b/projects/editor/src/app/text-editor/extensions/orderedList-extension.ts similarity index 95% rename from projects/editor/src/app/text-editor/orderedList-extension.ts rename to projects/editor/src/app/text-editor/extensions/orderedList-extension.ts index cffc604f3da33645b7d92137cd0c0f401b0ce705..a410c265d08a5b493f218350787bff88aa7266f9 100644 --- a/projects/editor/src/app/text-editor/orderedList-extension.ts +++ b/projects/editor/src/app/text-editor/extensions/orderedList-extension.ts @@ -10,7 +10,9 @@ declare module '@tiptap/core' { } } -export const orderedListExtension = OrderedList.extend({ +export const OrderedListExtension = OrderedList.extend({ + name: 'OrderedListExtension', + addAttributes() { return { listStyle: { diff --git a/projects/editor/src/app/text-editor/paragraph-extension.ts b/projects/editor/src/app/text-editor/extensions/paragraph-extension.ts similarity index 96% rename from projects/editor/src/app/text-editor/paragraph-extension.ts rename to projects/editor/src/app/text-editor/extensions/paragraph-extension.ts index 52415f312937196e2506895b343ec587cbcad506..ca9a2578639c0f53c98dbc14d437aeaa8c4d6765 100644 --- a/projects/editor/src/app/text-editor/paragraph-extension.ts +++ b/projects/editor/src/app/text-editor/extensions/paragraph-extension.ts @@ -10,7 +10,7 @@ declare module '@tiptap/core' { } } -export const customParagraph = Paragraph.extend({ +export const ParagraphExtension = Paragraph.extend({ addAttributes() { return { margin: { 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 0427a567a69db91e5ce3539c02cab5ad44b261ad..fc2106386606e119791e91bb02844778ad3c16f9 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 @@ -246,22 +246,23 @@ <mat-icon>format_quote</mat-icon> </button> </div> - <div *ngIf="showCloseElements" fxLayout="row" fxLayoutAlign="space-around center" > - <button mat-icon-button matTooltip="Eingabefeld (\i)" [matTooltipShowDelay]="300" - (click)="insertSpecialChar('\\i')"> + <div *ngIf="clozeMode" fxLayout="row" fxLayoutAlign="space-around center" > + <button mat-icon-button matTooltip="Eingabefeld" [matTooltipShowDelay]="300" + (click)="insertTextField()"> <mat-icon>text_fields</mat-icon> </button> - <button mat-icon-button matTooltip="Ablegefeld (\z)" [matTooltipShowDelay]="300" - (click)="insertSpecialChar('\\z')"> + <button mat-icon-button matTooltip="Ablegeliste" [matTooltipShowDelay]="300" + (click)="insertDropList()"> <mat-icon>drag_indicator</mat-icon> </button> - <button mat-icon-button matTooltip="Optionsfeld (\r)" [matTooltipShowDelay]="300" - (click)="insertSpecialChar('\\r')"> + <button mat-icon-button matTooltip="Optionsfeld" [matTooltipShowDelay]="300" + (click)="insertToggleButton()"> <mat-icon>radio_button_checked</mat-icon> </button> </div> </div> -<tiptap-editor [editor]="editor" [ngModel]="text" +<tiptap-editor [editor]="editor" [ngModel]="content" + [outputFormat]="clozeMode ? 'json' : 'html'" [style.font-size.px]="defaultFontSize" - (ngModelChange)="textChange.emit($event)"> + (ngModelChange)="contentChange.emit($event)"> </tiptap-editor> 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 e0038ceb306611345e19b666e42a150df96cf901..29dca96a34053d27cb01cecd6f4c1a0c4d8b2787 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 @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, Output, ViewEncapsulation, - AfterViewInit + AfterViewInit, Injector } from '@angular/core'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; @@ -14,14 +14,20 @@ import { TextAlign } from '@tiptap/extension-text-align'; import { Heading } from '@tiptap/extension-heading'; import { Image } from '@tiptap/extension-image'; import { Blockquote } from '@tiptap/extension-blockquote'; -import { Indent } from './indent'; -import { HangingIndent } from './hanging-indent'; -import { customParagraph } from './paragraph-extension'; -import { fontSizeExtension } from './font-size-extension'; -import { bulletListExtension } from './bulletList-extension'; -import { orderedListExtension } from './orderedList-extension'; +import { Indent } from './extensions/indent'; +import { HangingIndent } from './extensions/hanging-indent'; +import { ParagraphExtension } from './extensions/paragraph-extension'; +import { FontSizeExtension } from './extensions/font-size'; +import { BulletListExtension } from './extensions/bullet-list'; +import { OrderedListExtension } from './extensions/orderedList-extension'; + import { FileService } from '../services/file.service'; +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 { ClozeDocument } from '../../../../common/models/uI-element'; + @Component({ selector: 'app-rich-text-editor', templateUrl: './rich-text-editor.component.html', @@ -29,10 +35,10 @@ import { FileService } from '../services/file.service'; encapsulation: ViewEncapsulation.None }) export class RichTextEditorComponent implements AfterViewInit { - @Input() text!: string; + @Input() content!: string | Record<string, any>; @Input() defaultFontSize!: number; - @Input() showCloseElements: boolean | undefined = false; - @Output() textChange = new EventEmitter<string>(); + @Input() clozeMode: boolean = false; + @Output() contentChange = new EventEmitter<string | Record<string, any>>(); selectedFontColor = 'lightgrey'; selectedHighlightColor = 'lightgrey'; @@ -58,10 +64,10 @@ export class RichTextEditorComponent implements AfterViewInit { Heading.configure({ levels: [1, 2, 3, 4] }), - customParagraph, - fontSizeExtension, - bulletListExtension, - orderedListExtension, + ParagraphExtension, + FontSizeExtension, + BulletListExtension, + OrderedListExtension, HangingIndent, Image.configure({ inline: true, @@ -69,10 +75,15 @@ export class RichTextEditorComponent implements AfterViewInit { style: 'display: inline-block; height: 1em; vertical-align: middle' } }), - Blockquote + Blockquote, + ToggleButtonComponentExtension(this.injector), + DropListComponentExtension(this.injector), + TextFieldComponentExtension(this.injector) ] }); + constructor(private injector: Injector) { } + ngAfterViewInit(): void { this.editor.commands.focus(); } @@ -186,4 +197,19 @@ export class RichTextEditorComponent implements AfterViewInit { toggleBlockquote(): void { this.editor.commands.toggleBlockquote(); } + + insertToggleButton(): void { + this.editor.commands.insertContent('<app-nodeview-toggle-button></app-nodeview-toggle-button>'); + this.editor.commands.focus(); + } + + insertDropList(): void { + this.editor.commands.insertContent('<app-nodeview-drop-list></app-nodeview-drop-list>'); + this.editor.commands.focus(); + } + + insertTextField(): void { + this.editor.commands.insertContent('<app-nodeview-text-field></app-nodeview-text-field>'); + this.editor.commands.focus(); + } } diff --git a/projects/editor/src/app/util/cloze-parser.ts b/projects/editor/src/app/util/cloze-parser.ts index 9a7d75cf5417262124c3da5add0203acf9dcd85f..7805dcd3e964f664a8a114485b506d0059c07637 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);