From 15aec701407e9fe9a8f3dd99e4543d0c2c0f4152 Mon Sep 17 00:00:00 2001 From: jojohoch <joachim.hoch@iqb.hu-berlin.de> Date: Mon, 25 Nov 2024 10:46:22 +0100 Subject: [PATCH] Refactor TextAreaMath - Replace top layer with inline editor - Make keypad and keyboard available for TextAreaMath --- docs/unit_definition_changelog.txt | 9 + .../input-elements/math-field.component.ts | 4 +- .../text-area-math.component.ts | 190 ------------------ .../text-area-math/area-segment.component.ts | 72 +++++++ .../area-text-input.component.ts | 93 +++++++++ .../text-area-math.component.ts | 173 ++++++++++++++++ .../form-element-component.directive.ts | 4 + projects/common/interfaces.ts | 4 +- projects/common/math-editor.module.ts | 177 ++++++---------- .../elements/input-elements/text-area-math.ts | 37 +++- .../services/range-selection-service.ts | 84 ++++++++ projects/common/shared.module.ts | 9 +- .../preset-value-properties.component.ts | 4 +- .../modules/logging/services/log.service.ts | 2 +- .../element-group-selection.component.ts | 6 +- .../input-group-element.component.html | 5 - .../input-group-element.component.ts | 2 - .../text-input-group-element.component.html | 8 + .../text-input-group-element.component.ts | 2 + .../player-layout/player-layout.component.ts | 4 +- .../directives/text-input-group.directive.ts | 133 +++++++----- ...ment-model-element-code-mapping.service.ts | 2 + .../player/src/app/services/input-service.ts | 5 +- .../src/app/services/keyboard.service.ts | 8 +- .../player/src/app/services/keypad.service.ts | 8 +- 25 files changed, 657 insertions(+), 388 deletions(-) delete mode 100644 projects/common/components/input-elements/text-area-math.component.ts create mode 100644 projects/common/components/input-elements/text-area-math/area-segment.component.ts create mode 100644 projects/common/components/input-elements/text-area-math/area-text-input.component.ts create mode 100644 projects/common/components/input-elements/text-area-math/text-area-math.component.ts create mode 100644 projects/common/services/range-selection-service.ts diff --git a/docs/unit_definition_changelog.txt b/docs/unit_definition_changelog.txt index 03cc6239d..042e4f737 100644 --- a/docs/unit_definition_changelog.txt +++ b/docs/unit_definition_changelog.txt @@ -198,3 +198,12 @@ iqb-aspect-definition@1.0.0 - New Element "MarkingPanel" - all elements: new property "alias" + +- TextAreaMath + - new property: inputAssistancePreset + - new property: inputAssistancePosition + - new property: inputAssistanceFloatingStartPosition + - new property: showSoftwareKeyboard + - new property: hideNativeKeyboard + - new property: addInputAssistanceToKeyboard + - new property: hasArrowKeys diff --git a/projects/common/components/input-elements/math-field.component.ts b/projects/common/components/input-elements/math-field.component.ts index 55a46e1f3..e9d1c7c50 100644 --- a/projects/common/components/input-elements/math-field.component.ts +++ b/projects/common/components/input-elements/math-field.component.ts @@ -17,12 +17,12 @@ import { MathFieldElement } from 'common/models/elements/input-elements/math-fie [style.text-decoration]="elementModel.styling.underline ? 'underline' : ''" [style.background-color]="elementModel.styling.backgroundColor"> <label>{{elementModel.label}}</label><br> - <aspect-mathlive-math-field [value]="$any(elementModel.value) | getValue: elementFormControl.value : parentForm" + <aspect-math-input [value]="$any(elementModel.value) | getValue: elementFormControl.value : parentForm" [readonly]="elementModel.readOnly" [enableModeSwitch]="elementModel.enableModeSwitch" (input)="elementFormControl.setValue($any($event.target).value)" (focusout)="elementFormControl.markAsTouched()"> - </aspect-mathlive-math-field> + </aspect-math-input> <mat-error *ngIf="elementFormControl.errors && elementFormControl.touched" class="error-message"> {{elementFormControl.errors | errorTransform: elementModel}} diff --git a/projects/common/components/input-elements/text-area-math.component.ts b/projects/common/components/input-elements/text-area-math.component.ts deleted file mode 100644 index a69479ea6..000000000 --- a/projects/common/components/input-elements/text-area-math.component.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { - Component, ElementRef, - Input, OnInit, Renderer2, ViewChild -} from '@angular/core'; -import { TextAreaMathElement } from 'common/models/elements/input-elements/text-area-math'; -import { MathInputComponent } from 'common/math-editor.module'; -import { FormElementComponent } from 'common/directives/form-element-component.directive'; -import { OverlayContainer } from '@angular/cdk/overlay'; - -@Component({ - selector: 'aspect-text-area-math', - template: ` - <button #insertFormulaButton - mat-icon-button [matTooltip]="'Formel einfügen'" - [matTooltipPosition]="'above'" - cdkOverlayOrigin #trigger="cdkOverlayOrigin" - [disabled]="!textareaIsFocused" - (click)="toggleFormulaOverlay()"> - <mat-icon>functions</mat-icon> - </button> - <ng-template cdkConnectedOverlay - [cdkConnectedOverlayDisableClose]="true" - [cdkConnectedOverlayOrigin]="trigger" - [cdkConnectedOverlayOpen]="isOverlayOpen"> - <div class="formula-overlay" - cdkDrag> - Formeleingabe - <aspect-mathlive-math-field #mathfield> - </aspect-mathlive-math-field> - <div [style.display]="'flex'" [style.flex-direction]="'row'"> - <button mat-button (click)="onConfirmFormula()"> - {{'confirm' | translate }} - </button> - <button mat-button (click)="cancelFormula()"> - {{'cancel' | translate }} - </button> - </div> - </div> - </ng-template> - <!-- Always set min-height to rowCount --> - <!-- Fix height when set, so that area scrolls --> - <div class="textarea" - [style.min-height.px]="(elementModel.styling.fontSize * (elementModel.styling.lineHeight / 100)) * - elementModel.rowCount" - [style.height.px]="!elementModel.hasAutoHeight && - (elementModel.styling.fontSize * (elementModel.styling.lineHeight / 100)) - * elementModel.rowCount"> - <span #textarea contenteditable="true" - [innerHTML]="(savedValue ? savedValue : elementModel.value) | safeResourceHTML" - (keydown)="elementModel.readOnly ? $event.preventDefault() : null" - (paste)="elementModel.readOnly ? $event.preventDefault() : null" - [style.display]="'block'" - [style.overflow-y]="!elementModel.hasAutoHeight && 'auto'" - [style.background-color]="elementModel.styling.backgroundColor" - [style.line-height.%]="elementModel.styling.lineHeight" - [style.color]="elementModel.styling.fontColor" - [style.font-family]="elementModel.styling.font" - [style.font-size.px]="elementModel.styling.fontSize" - [style.font-weight]="elementModel.styling.bold ? 'bold' : ''" - [style.font-style]="elementModel.styling.italic ? 'italic' : ''" - [style.text-decoration]="elementModel.styling.underline ? 'underline' : ''" - (focus)="textareaIsFocused=true" - (blur)="onBlur($event)" - (input)="elementFormControl.setValue(textArea.nativeElement.innerHTML)"> - </span> - </div> - <mat-error *ngIf="elementFormControl.errors && elementFormControl.touched"> - {{elementFormControl.errors | errorTransform: elementModel}} - </mat-error> - `, - styles: [ - ':host {display: flex; flex-direction: column; height: 100%;}', - // margin for showing focus-outline - '.textarea {border: 1px solid black; border-radius: 3px; flex-grow: 1; margin: 1px;}', - '.textarea span {height: 100%; word-wrap: anywhere;}', - '.textarea span:focus {outline: 2px solid #3f51b5;}', - 'aspect-mathlive-math-field {font-size: x-large;}', - ':host ::ng-deep math-field:focus {outline: 2px solid #3f51b5;}', - '.formula {display: inline;}', - '.formula-overlay {display: flex; flex-direction: column; background-color: lightgray; padding: 10px;}', - '.formula-overlay aspect-mathlive-math-field {background-color: white;}' - ] -}) -export class TextAreaMathComponent extends FormElementComponent implements OnInit { - @Input() elementModel!: TextAreaMathElement; - @ViewChild('textarea') textArea!: ElementRef; - @ViewChild('insertFormulaButton', { read: ElementRef }) insertFormulaButton!: ElementRef; - @ViewChild('mathfield') mathfieldRef!: MathInputComponent; - savedValue: string = ''; - isOverlayOpen = false; - range!: Range; - formulaIndex!: number; - textareaIsFocused: boolean = false; - - constructor(public elementRef: ElementRef, - private renderer: Renderer2, - private overlayContainer: OverlayContainer) { - super(elementRef); - } - - ngOnInit(): void { - super.ngOnInit(); - if (this.parentForm) this.savedValue = this.elementFormControl.value; - } - - onConfirmFormula(): void { - this.isOverlayOpen = false; - this.overlayContainer.getContainerElement().classList.remove('reduced-overlay-index'); - - if (this.elementModel.readOnly) return; - const formula = this.createFormula(this.mathfieldRef.getMathMLValue()); - this.insertFormula(formula); - - this.updateTextArea(); - this.elementFormControl.setValue(this.textArea.nativeElement.innerHTML); - this.setFocusAndCursor(); - } - - onBlur(event: FocusEvent): void { - if (event.relatedTarget !== this.insertFormulaButton.nativeElement) { - this.textareaIsFocused = false; - this.elementFormControl.markAsTouched(); - } - } - - private createFormula(newContent: string): Element { - const formulaWrapper = this.renderer.createElement('span'); - this.renderer.setAttribute(formulaWrapper, 'contenteditable', 'true'); - this.renderer.addClass(formulaWrapper, 'formula'); - - const mathElement = this.renderer.createElement('math'); - this.renderer.setStyle(mathElement, 'display', 'inline-block'); - this.renderer.setProperty(mathElement, 'innerHTML', newContent); - this.renderer.appendChild(formulaWrapper, mathElement); - - return formulaWrapper; - } - - private insertFormula(formulaWrapper: Element): void { - const documentFragment = document.createDocumentFragment(); - // Insert space before - documentFragment.appendChild(document.createTextNode('\xA0')); - const formulaNode = documentFragment.appendChild(formulaWrapper); - // Insert space after - documentFragment.appendChild(document.createTextNode('\xA0')); - this.range.deleteContents(); - this.range.insertNode(documentFragment); - const elements = Array.from(this.textArea.nativeElement.childNodes); - this.formulaIndex = elements.indexOf(formulaNode); - } - - private setFocusAndCursor(): void { - const range = document.createRange(); - const selection = window.getSelection() as Selection; - range.setStart(this.textArea.nativeElement.childNodes[this.formulaIndex], 1); - selection.removeAllRanges(); - selection.addRange(range); - this.textArea.nativeElement.focus(); - } - - private updateTextArea(): void { - this.renderer.setProperty(this.textArea.nativeElement, 'innerHTML', this.textArea.nativeElement.innerHTML); - } - - toggleFormulaOverlay(): void { - if (this.isOverlayOpen) { - this.overlayContainer.getContainerElement().classList.remove('reduced-overlay-index'); - } else { - this.overlayContainer.getContainerElement().classList.add('reduced-overlay-index'); - } - this.isOverlayOpen = !this.isOverlayOpen; - this.setRange(); - } - - private setRange(): void { - const selection = window.getSelection() as Selection; - if (selection && selection.rangeCount > 0) { - this.range = selection.getRangeAt(0); - } - } - - cancelFormula() { - this.isOverlayOpen = false; - this.overlayContainer.getContainerElement().classList.remove('reduced-overlay-index'); - const selection = window.getSelection() as Selection; - selection.removeAllRanges(); - selection.addRange(this.range); - this.textArea.nativeElement.focus(); - } -} diff --git a/projects/common/components/input-elements/text-area-math/area-segment.component.ts b/projects/common/components/input-elements/text-area-math/area-segment.component.ts new file mode 100644 index 000000000..e4734a675 --- /dev/null +++ b/projects/common/components/input-elements/text-area-math/area-segment.component.ts @@ -0,0 +1,72 @@ +import { + Component, EventEmitter, Input, Output, ViewChild +} from '@angular/core'; +import { MathEditorModule, MathInputComponent } from 'common/math-editor.module'; +import { AreaTextInputComponent } from 'common/components/input-elements/text-area-math/area-text-input.component'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'aspect-text-area-math-segment', + template: ` + @if (type === 'math') { + <aspect-math-input #inputComponent + [fullWidth]="false" + [value]="value" + (focusIn)="selectedFocus.next(this.index)" + (valueChange)="valueChanged.emit({ index: index, value: $event})"> + </aspect-math-input> + } @else { + <aspect-area-input #inputComponent + [value]="value" + (focusIn)="onFocusIn($event)" + (focusOut)="onFocusOut($event)" + (onKeyDown)="onKeyDown.emit($event)" + (valueChanged)="valueChanged.emit({ index: index, value: $event})" + (remove)="onRemove($event)"> + </aspect-area-input> + } + `, + standalone: true, + imports: [ + AreaTextInputComponent, + MathEditorModule + ], + styles: [] +}) +export class AreaSegmentComponent { + @Input() type!: 'text' | 'math'; + @Input() value!: string; + @Input() index!: number; + @Input() selectedFocus!: BehaviorSubject<number>; + @Output() valueChanged: EventEmitter<{ index: number; value: string }> = new EventEmitter(); + @Output() remove: EventEmitter<number> = new EventEmitter(); + @Output() focusIn: EventEmitter<HTMLElement> = new EventEmitter(); + @Output() focusOut: EventEmitter<HTMLElement> = new EventEmitter(); + @Output() onKeyDown = new EventEmitter<{ + keyboardEvent: KeyboardEvent; + inputElement: HTMLElement; + }>(); + + @ViewChild('inputComponent') inputComponent!: AreaTextInputComponent | MathInputComponent; + + setFocus(offset?: number) { + // if (document.activeElement instanceof HTMLElement) { + // document.activeElement.blur(); + // } + this.inputComponent.setFocus(offset); + } + + onFocusIn(input: HTMLElement) { + this.selectedFocus.next(this.index); + this.focusIn.emit(input); + } + + onFocusOut(input: HTMLElement) { + this.focusOut.emit(input); + } + + onRemove(key: 'Delete' | 'Backspace') { + const target = key === 'Backspace' ? this.index - 1 : this.index + 1; + this.remove.emit(target); + } +} diff --git a/projects/common/components/input-elements/text-area-math/area-text-input.component.ts b/projects/common/components/input-elements/text-area-math/area-text-input.component.ts new file mode 100644 index 000000000..1ed3ead77 --- /dev/null +++ b/projects/common/components/input-elements/text-area-math/area-text-input.component.ts @@ -0,0 +1,93 @@ +import { + Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild +} from '@angular/core'; +import { RangeSelectionService } from 'common/services/range-selection-service'; + +@Component({ + selector: 'aspect-area-input', + template: ` + <span #inputRef class="input" + [style.display]="displayType" + [contentEditable]="true" + [textContent]="value" + (focusin)="onFocusIn(inputRef)" + (focusout)="onFocusOut(inputRef)" + (blur)="onFocusOut(inputRef)" + (keydown)="keyDown($event)" + (keyup)="keyUp($event)" + (input)="onInput()"></span> + `, + styles: [` + .input { + padding: 0 14px; + outline: none; + white-space: pre-line; + } + `], + standalone: true +}) +export class AreaTextInputComponent implements OnInit { + removePressed: boolean = false; + displayType: string = 'inline-block'; + + @Input() value!: string; + @Output() valueChanged: EventEmitter<string> = new EventEmitter(); + @Output() focusIn: EventEmitter<HTMLElement> = new EventEmitter(); + @Output() focusOut: EventEmitter<HTMLElement> = new EventEmitter(); + @Output() remove: EventEmitter<'Delete' | 'Backspace'> = new EventEmitter(); + @Output() onKeyDown = new EventEmitter<{ + keyboardEvent: KeyboardEvent; + inputElement: HTMLElement; + }>(); + + @ViewChild('inputRef') inputRef!: ElementRef; + + ngOnInit(): void { + this.setDisplayType(this.value); + } + + setFocus(offset?: number) { + if (offset) { + const range = new Range(); + const sel = window.getSelection(); + range.setStart(this.inputRef.nativeElement.firstChild, offset); + range.setEnd(this.inputRef.nativeElement.firstChild, offset); + sel?.removeAllRanges(); + sel?.addRange(range); + this.inputRef.nativeElement.focus(); + } else { + this.inputRef.nativeElement.focus(); + } + } + + onInput() { + this.valueChanged.emit(this.inputRef.nativeElement.textContent); + this.setDisplayType(this.inputRef.nativeElement.textContent); + } + + private setDisplayType(value: string) { + // Fix cursor in empty input in chromium + this.displayType = value ? 'inline' : 'inline-block'; + } + + keyDown(event: KeyboardEvent) { + const range = RangeSelectionService.getRange(); + this.removePressed = !!((event.key === 'Backspace' && range && range.startOffset === 0 && range.endOffset === 0) || + (event.key === 'Delete' && range && range.startOffset === range.endOffset && + range.endOffset === range.endContainer.textContent?.length)); + this.onKeyDown.emit({ keyboardEvent: event, inputElement: this.inputRef.nativeElement }); + } + + keyUp(e: KeyboardEvent): void { + if (this.removePressed) this.remove.emit(e.key as 'Delete' | 'Backspace'); + this.removePressed = false; + } + + onFocusIn(input: HTMLElement) { + this.focusIn.emit(input); + } + + onFocusOut(input: HTMLElement) { + this.focusOut.emit(input); + } +} diff --git a/projects/common/components/input-elements/text-area-math/text-area-math.component.ts b/projects/common/components/input-elements/text-area-math/text-area-math.component.ts new file mode 100644 index 000000000..758ea4c31 --- /dev/null +++ b/projects/common/components/input-elements/text-area-math/text-area-math.component.ts @@ -0,0 +1,173 @@ +import { + Component, ElementRef, EventEmitter, Input, OnInit, Output, QueryList, ViewChild, ViewChildren +} from '@angular/core'; +import { TextAreaMathElement, TextAreaMath } from 'common/models/elements/input-elements/text-area-math'; +import { FormElementComponent } from 'common/directives/form-element-component.directive'; +import { + AreaSegmentComponent +} from 'common/components/input-elements/text-area-math/area-segment.component'; +import { BehaviorSubject } from 'rxjs'; +import { RangeSelectionService } from 'common/services/range-selection-service'; + +@Component({ + selector: 'aspect-text-area-math', + template: ` + <button class="insert-formula-button" + mat-button + cdkOverlayOrigin #trigger="cdkOverlayOrigin" + (click)="addFormula()"> + Formel einfügen + </button> + <div #textArea class="text-area" + [style.min-height.px]="(elementModel.styling.fontSize * (elementModel.styling.lineHeight / 100)) * + elementModel.rowCount" + [style.height.px]="!elementModel.hasAutoHeight && + (elementModel.styling.fontSize * (elementModel.styling.lineHeight / 100)) + * elementModel.rowCount" + [style.overflow-y]="!elementModel.hasAutoHeight && 'auto'" + [style.background-color]="elementModel.styling.backgroundColor" + [style.line-height.%]="elementModel.styling.lineHeight" + [style.color]="elementModel.styling.fontColor" + [style.font-family]="elementModel.styling.font" + [style.font-size.px]="elementModel.styling.fontSize" + [style.font-weight]="elementModel.styling.bold ? 'bold' : ''" + [style.font-style]="elementModel.styling.italic ? 'italic' : ''" + [style.text-decoration]="elementModel.styling.underline ? 'underline' : ''"> + <span class="alignment-fix"> </span> + @for (segment of segments; track segment; let i = $index) { + <aspect-text-area-math-segment + [style.display]="'inline'" + [type]="segment.type" + [value]="segment.value" + [selectedFocus]="selectedFocus" + (valueChanged)="onValueChanged($event)" + [index]="i" + (focusIn)="focusChanged.emit({ inputElement: $event, focused: true })" + (focusOut)="focusChanged.emit({ inputElement: $event, focused: false })" + (onKeyDown)="onKeyDown.emit($event)" + (remove)="removeSegment($event)"> + </aspect-text-area-math-segment> + } + </div> + <mat-error *ngIf="elementFormControl.errors && elementFormControl.touched"> + {{ elementFormControl.errors | errorTransform: elementModel }} + </mat-error> + `, + styles: [ + '.alignment-fix {padding: 15px 0; display: inline-block; width: 0;}', + '.text-area {border: 1px solid black; border-radius: 3px; padding: 5px;}', + ':host {display: flex; flex-direction: column; height: 100%;}', + '.insert-formula-button {font-size: large; width: 160px; background-color: #ddd; padding: 15px 10px; height: 55px;}' + ] +}) +export class TextAreaMathComponent extends FormElementComponent implements OnInit { + @Input() elementModel!: TextAreaMathElement; + @Output() mathInputFocusIn: EventEmitter<FocusEvent> = new EventEmitter(); + @Output() mathInputFocusOut: EventEmitter<FocusEvent> = new EventEmitter(); + @Output() focusChanged = new EventEmitter<{ inputElement: HTMLElement; focused: boolean }>(); + @Output() onKeyDown = new EventEmitter<{ + keyboardEvent: KeyboardEvent; + inputElement: HTMLElement; + }>(); + + @ViewChildren(AreaSegmentComponent) segmentComponents!: QueryList<AreaSegmentComponent>; + @ViewChild('textArea') textArea!: ElementRef; + + segments: TextAreaMath[] = []; + selectedFocus: BehaviorSubject<number> = new BehaviorSubject<number>(0); + + ngOnInit(): void { + super.ngOnInit(); + if (this.parentForm) this.segments = this.elementFormControl.value; + if (this.segments.length === 0) { + this.segments.push({ type: 'text', value: '' }); + super.setElementValue(this.segments); + } + } + + addFormula() { + this.segments = this.elementFormControl.value; + this.updateFocus(this.selectedFocus.value); + const range = RangeSelectionService.getRange(); + if (!range) return; + + const segmentIndex = this.selectedFocus.value; + let newSegmentIndex = segmentIndex + 1; + const selectedType = this.segments[this.selectedFocus.value].type; + if (selectedType === 'text') { + const content = range.endContainer.parentElement?.textContent || ''; + const { start, end } = RangeSelectionService + .getSelectionRange(range, this.segmentComponents.toArray()[segmentIndex].inputComponent.inputRef.nativeElement); + if (content.length === start) { + this.addSegments(false, true, segmentIndex, '', ''); + } else { + const startContent = content.slice(0, start); + const endContent = content.slice(end); + this.segments[segmentIndex].value = startContent; + this.addSegments(false, true, segmentIndex, '', endContent); + } + } else { + newSegmentIndex += 1; + this.addSegments(true, false, segmentIndex, '', ''); + } + super.setElementValue(this.segments); + setTimeout(() => this.updateFocus(newSegmentIndex), 250); + } + + private updateFormControl(value: TextAreaMath[]): void { + super.setElementValue(value); + } + + private addSegments( + addStartContent: boolean, + addEndContent: boolean, + segmentIndex: number, + startContent: string, + endContent: string) { + const targetSegmentIndex = addStartContent ? segmentIndex + 2 : segmentIndex + 1; + if (addStartContent) this.segments.splice(targetSegmentIndex - 1, 0, { type: 'text', value: startContent }); + this.segments.splice(targetSegmentIndex, 0, { type: 'math', value: '' }); + if (addEndContent) this.segments.splice(targetSegmentIndex + 1, 0, { type: 'text', value: endContent }); + } + + removeSegment(index: number) { + const segments: TextAreaMath[] = [...this.elementFormControl.value]; + if (segments[index] && segments[index].type === 'math') { + segments.splice(index, 1); + // combine text segments + segments[index - 1].value += segments[index].value; + const offset = segments[index - 1].value.length; + segments.splice(index, 1); + this.segments = segments; + super.setElementValue(this.segments); + // wait for rendering of segments + setTimeout(() => this.updateFocus(index - 1, offset)); + } + } + + onValueChanged(value: { index: number; value: string }): void { + const segments = [...this.elementFormControl.value]; + super.setElementValue( + segments + .map((segment, index) => ({ + ...value.index === index ? { type: segment.type, value: value.value } : segment + }))); + } + + private setSegmentValue(value: { index: number; value: string }) { + this.segments[value.index].value = value.value; + this.updateFormControl(this.segments); + } + + setElementValue(value: string, remove?: boolean): void { + if (remove) { + this.removeSegment(this.selectedFocus.value - 1); + } else { + this.setSegmentValue({ index: this.selectedFocus.value, value: value }); + } + } + + private updateFocus(index: number, offset?: number): void { + this.segmentComponents.toArray()[index].setFocus(offset); + } +} diff --git a/projects/common/directives/form-element-component.directive.ts b/projects/common/directives/form-element-component.directive.ts index 476bae77e..6b7e8c106 100644 --- a/projects/common/directives/form-element-component.directive.ts +++ b/projects/common/directives/form-element-component.directive.ts @@ -12,4 +12,8 @@ export abstract class FormElementComponent extends ElementComponent implements O this.parentForm.controls[this.elementModel.id] as UntypedFormControl : new UntypedFormControl(this.elementModel.value); } + + setElementValue(value: unknown, option?: unknown): void { + this.elementFormControl.setValue(value); + } } diff --git a/projects/common/interfaces.ts b/projects/common/interfaces.ts index 743ccd4ba..fffaa90ea 100644 --- a/projects/common/interfaces.ts +++ b/projects/common/interfaces.ts @@ -11,7 +11,8 @@ import { import { VisibilityRule } from 'common/models/visibility-rule'; import { UIElement } from 'common/models/elements/element'; import { MathTableRow } from 'common/models/elements/input-elements/math-table'; -import { Markable } from 'player/src/app/models/markable.interface'; // TODO +import { Markable } from 'player/src/app/models/markable.interface'; +import { TextAreaMath } from 'common/models/elements/input-elements/text-area-math'; export type UIElementType = 'text' @@ -88,6 +89,7 @@ export type InputElementValue = | TextLabel[] | Hotspot[] | MathTableRow[] + | TextAreaMath[] | GeometryValue | string[] | string diff --git a/projects/common/math-editor.module.ts b/projects/common/math-editor.module.ts index 7da9ccf15..edc3fa49d 100644 --- a/projects/common/math-editor.module.ts +++ b/projects/common/math-editor.module.ts @@ -1,15 +1,14 @@ // eslint-disable-next-line max-classes-per-file import { NgModule, CUSTOM_ELEMENTS_SCHEMA, - Component, AfterViewInit, ViewChild, ElementRef, Input, OnChanges, SimpleChanges + Component, AfterViewInit, ViewChild, ElementRef, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { MathfieldElement, VirtualKeyboardDefinition } from 'mathlive'; -import { VirtualKeyboardLayer } from 'mathlive/dist/public/options'; +import { MathfieldElement } from 'mathlive'; import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/button-toggle'; @Component({ - selector: 'aspect-mathlive-math-field', + selector: 'aspect-math-input', template: ` <mat-button-toggle-group *ngIf="enableModeSwitch" [value]="mathFieldElement.mode" @@ -17,15 +16,36 @@ import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/ <mat-button-toggle value="math">Formel</mat-button-toggle> <mat-button-toggle value="text">Text</mat-button-toggle> </mat-button-toggle-group> - <div #mathfield [class.read-only]="readonly"> + <div #inputRef + (input)="onInput()" + [class.full-width]="fullWidth" + [class.inline-block]="!fullWidth" + [class.read-only]="readonly" + (focusin)="onFocusIn($event)" + (focusout)="onFocusOut($event)"> </div> `, styles: [` mat-button-toggle-group { height: auto; } + :host ::ng-deep .full-width math-field { + display: block; + } + + .inline-block{ + display: inline-block; + } + + :host ::ng-deep math-field::part(virtual-keyboard-toggle) { + display: none; + } + :host ::ng-deep math-field::part(menu-toggle) { + display: none; + } :host ::ng-deep .read-only math-field { - outline: unset; border: unset; + outline: unset; + border: unset; } :host ::ng-deep .mat-button-toggle-label-content { line-height: unset; @@ -34,147 +54,66 @@ import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/ }) export class MathInputComponent implements AfterViewInit, OnChanges { @Input() value!: string; + @Input() fullWidth: boolean = true; @Input() readonly: boolean = false; @Input() enableModeSwitch: boolean = false; - @ViewChild('mathfield') mathfieldRef!: ElementRef; + @Output() valueChange: EventEmitter<string> = new EventEmitter(); + @Output() focusIn: EventEmitter<FocusEvent> = new EventEmitter(); + @Output() focusOut: EventEmitter<FocusEvent> = new EventEmitter(); + @ViewChild('inputRef') inputRef!: ElementRef; + @ViewChild('container') container!: ElementRef; + + protected readonly window = window; mathFieldElement: MathfieldElement = new MathfieldElement({ - virtualKeyboardMode: 'onfocus', - customVirtualKeyboardLayers: MathInputComponent.setupKeyboadLayer(), - customVirtualKeyboards: MathInputComponent.setupKeyboard(), - virtualKeyboards: 'aspect-keyboard roman greek', - keypressSound: null, - plonkSound: null, - decimalSeparator: ',', - smartFence: false - // defaultMode: 'math' + mathVirtualKeyboardPolicy: 'manual' }); + constructor(public elementRef: ElementRef) { } + ngAfterViewInit(): void { this.setupMathfield(); } - setupMathfield(): void { - this.mathfieldRef.nativeElement.appendChild(this.mathFieldElement); + private setupMathfield(): void { + this.inputRef.nativeElement.appendChild(this.mathFieldElement); this.mathFieldElement.value = this.value; this.mathFieldElement.readOnly = this.readonly; } - static setupKeyboard(): Record<string, VirtualKeyboardDefinition> { - return { - 'aspect-keyboard': { - label: 'Formel', // Label displayed in the Virtual Keyboard Switcher - tooltip: 'Zahlen & Formeln', // Tooltip when hovering over the label - layer: 'aspect-keyboard-layer' - } - }; - } - - static setupKeyboadLayer(): Record<string, string | Partial<VirtualKeyboardLayer>> { - return { - 'aspect-keyboard-layer': { - styles: '', - rows: [ - [ - { label: '7', key: '7' }, - { label: '8', key: '8' }, - { label: '9', key: '9' }, - { class: 'separator w5' }, - { latex: '+' }, - { class: 'separator w5' }, - { latex: '<' }, - { latex: '>' }, - { latex: '\\ne' }, - { class: 'separator w5' }, - { latex: '€' }, - { class: 'separator w5' }, - { label: '<span><i>x</i> ²</span>', insert: '$$#@^{2}$$' }, - { latex: '$$#@^{#?}' }, - { latex: '$$#@_{#?}' } - ], - [ - { label: '4', latex: '4' }, - { label: '5', key: '5' }, - { label: '6', key: '6' }, - { class: 'separator w5' }, - { latex: '-' }, - { class: 'separator w5' }, - { latex: '\\le' }, - { latex: '\\ge' }, - { latex: '\\approx' }, - { class: 'separator w5' }, - { latex: '\\%' }, - { class: 'separator w5' }, - { class: 'small', latex: '\\frac{#0}{#0}' }, - { latex: '\\sqrt{#0}', insert: '$$\\sqrt{#0}$$' }, - { class: 'separator' } - ], - [ - { label: '1', key: '1' }, - { label: '2', key: '2' }, - { label: '3', key: '3' }, - { class: 'separator w5' }, - { latex: '\\times' }, - { class: 'separator w5' }, - { latex: '(' }, - { latex: ')' }, - { latex: '\\Rightarrow' }, - { class: 'separator w5' }, - { latex: '°' }, - { class: 'separator w5' }, - { latex: '\\overline' }, - { class: 'separator' }, - { class: 'separator' } - ], - [ - { label: '0', key: '0' }, - { latex: ',' }, - { latex: '=' }, - { class: 'separator w5' }, - { latex: '\\div' }, - { class: 'separator w5' }, - { latex: '[' }, - { latex: ']' }, - { latex: '\\Leftrightarrow' }, - { class: 'separator w5' }, - { latex: '\\mid' }, - { class: 'separator w5' }, - { - class: 'action', - label: "<svg><use xlink:href='#svg-arrow-left' /></svg>", - command: ['performWithFeedback', 'moveToPreviousChar'] - }, - { - class: 'action', - label: "<svg><use xlink:href='#svg-arrow-right' /></svg>", - command: ['performWithFeedback', 'moveToNextChar'] - }, - { - class: 'action font-glyph bottom right', - label: '⌫', - command: ['performWithFeedback', 'deleteBackward'] - } - ] - ] - } - }; - } - ngOnChanges(changes: SimpleChanges): void { if (changes.value) { this.mathFieldElement.setValue(changes.value.currentValue, { mode: 'text' }); } } + setFocus(offset?: number): void { + this.mathFieldElement.focus(); + } + setParseMode(event: MatButtonToggleChange) { // TODO Keyboard moving up and down on focus loss may be avoided by using useSharedVirtualKeyboard this.mathFieldElement.mode = event.value; - (this.mathfieldRef.nativeElement.childNodes[0] as HTMLElement).focus(); + (this.inputRef.nativeElement.childNodes[0] as HTMLElement).focus(); } getMathMLValue(): string { return this.mathFieldElement.getValue('math-ml'); } + + onInput() { + this.valueChange.emit(this.mathFieldElement.getValue()); + } + + onFocusIn(event: FocusEvent) { + this.focusIn.emit(event); + window.mathVirtualKeyboard.show(); + } + + onFocusOut(event: FocusEvent) { + window.mathVirtualKeyboard.hide(); + this.focusOut.emit(event); + } } @NgModule({ diff --git a/projects/common/models/elements/input-elements/text-area-math.ts b/projects/common/models/elements/input-elements/text-area-math.ts index aba3fc238..7f3d3e22c 100644 --- a/projects/common/models/elements/input-elements/text-area-math.ts +++ b/projects/common/models/elements/input-elements/text-area-math.ts @@ -9,16 +9,29 @@ import { import { Type } from '@angular/core'; import { ElementComponent } from 'common/directives/element-component.directive'; import { VariableInfo } from '@iqb/responses'; -import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math.component'; +import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math/text-area-math.component'; import { environment } from 'common/environment'; -import { AbstractIDService, InputElementProperties, UIElementType } from 'common/interfaces'; +import { + AbstractIDService, + InputAssistancePreset, + InputElementProperties, + KeyInputElementProperties, + UIElementType +} from 'common/interfaces'; import { InstantiationEror } from 'common/errors'; export class TextAreaMathElement extends InputElement implements TextAreaMathProperties { type: UIElementType = 'text-area-math'; - value = ''; + value: TextAreaMath[] = []; rowCount: number = 2; hasAutoHeight: boolean = false; + inputAssistancePreset: InputAssistancePreset = null; + inputAssistancePosition: 'floating' | 'right' = 'floating'; + inputAssistanceFloatingStartPosition: 'startBottom' | 'endCenter' = 'startBottom'; + showSoftwareKeyboard: boolean = false; + addInputAssistanceToKeyboard: boolean = false; + hideNativeKeyboard: boolean = false; + hasArrowKeys: boolean = false; position: PositionProperties; styling: BasicStyles & { lineHeight: number; @@ -32,15 +45,23 @@ export class TextAreaMathElement extends InputElement implements TextAreaMathPro if (isTextAreaMathProperties(element)) { this.rowCount = element.rowCount; this.hasAutoHeight = element.hasAutoHeight; + this.inputAssistancePreset = element.inputAssistancePreset; + this.inputAssistancePosition = element.inputAssistancePosition; + this.inputAssistanceFloatingStartPosition = element.inputAssistanceFloatingStartPosition; + this.showSoftwareKeyboard = element.showSoftwareKeyboard; + this.addInputAssistanceToKeyboard = element.addInputAssistanceToKeyboard; + this.hideNativeKeyboard = element.hideNativeKeyboard; + this.hasArrowKeys = element.hasArrowKeys; this.position = { ...element.position }; this.styling = { ...element.styling }; } else { if (environment.strictInstantiation) { throw new InstantiationEror('Error at TextAreaMath instantiation', element); } - if (element?.value !== undefined) this.value = element?.value as string; + if (element?.value !== undefined) this.value = element?.value as TextAreaMath[] || []; if (element?.rowCount !== undefined) this.rowCount = element.rowCount; if (element?.hasAutoHeight !== undefined) this.hasAutoHeight = element.hasAutoHeight; + Object.assign(this, PropertyGroupGenerators.generateKeyInputProps(element)); this.dimensions = PropertyGroupGenerators.generateDimensionProps(element?.dimensions); this.position = PropertyGroupGenerators.generatePositionProps(element?.position); this.styling = { @@ -70,7 +91,7 @@ export class TextAreaMathElement extends InputElement implements TextAreaMathPro } } -export interface TextAreaMathProperties extends InputElementProperties { +export interface TextAreaMathProperties extends InputElementProperties, KeyInputElementProperties { rowCount: number; hasAutoHeight: boolean; position: PositionProperties; @@ -79,11 +100,17 @@ export interface TextAreaMathProperties extends InputElementProperties { }; } +export interface TextAreaMath { + type: 'text' | 'math'; + value: string +} + function isTextAreaMathProperties(blueprint?: Partial<TextAreaMathProperties>): blueprint is TextAreaMathProperties { if (!blueprint) return false; return blueprint.rowCount !== undefined && blueprint.hasAutoHeight !== undefined && PropertyGroupValidators.isValidPosition(blueprint.position) && PropertyGroupValidators.isValidBasicStyles(blueprint.styling) && + PropertyGroupValidators.isValidKeyInputElementProperties(blueprint) && blueprint.styling?.lineHeight !== undefined; } diff --git a/projects/common/services/range-selection-service.ts b/projects/common/services/range-selection-service.ts new file mode 100644 index 000000000..43d391442 --- /dev/null +++ b/projects/common/services/range-selection-service.ts @@ -0,0 +1,84 @@ +export class RangeSelectionService { + static getRange(): Range | null { + const selection = window.getSelection() as Selection; + if (selection && selection.rangeCount > 0) { + return selection.getRangeAt(0); + } + return null; + } + + static getSelectionRange(range: Range, inputElement: HTMLElement): { start: number; end: number } { + let start = 0; + let end = 0; + + if (!inputElement.contains(range.commonAncestorContainer)) { + return { start, end }; + } + + const calculateOffsets = (node: Node): number => { + let offset = 0; + const walker = document.createTreeWalker(inputElement, NodeFilter.SHOW_TEXT, null); + let currentNode: Node | null = walker.nextNode(); + + while (currentNode) { + if (currentNode === node) { + break; + } + offset += currentNode.textContent?.length || 0; + currentNode = walker.nextNode(); + } + return offset; + }; + + start = calculateOffsets(range.startContainer) + range.startOffset; + end = calculateOffsets(range.endContainer) + range.endOffset; + + return { start, end }; + } + + static setSelectionRange( + inputElement: HTMLElement, + start: number, + end: number + ): void { + const range = new Range(); + const selection = window.getSelection(); + if (!selection) return; + + let charCount = 0; + + const setRangeOffsets = (node: Node): boolean => { + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length || 0; + + // Prüfen, ob Startpunkt im aktuellen Textknoten liegt + if (start >= charCount && start <= charCount + textLength) { + range.setStart(node, start - charCount); + } + + // Prüfen, ob Endpunkt im aktuellen Textknoten liegt + if (end >= charCount && end <= charCount + textLength) { + range.setEnd(node, end - charCount); + return true; // Fertig, wenn beide Punkte gesetzt sind + } + + charCount += textLength; + } else { + // Rekursive Durchsuchung von Kindknoten + const childNodes = node.childNodes; + for (let i = 0; i < childNodes.length; i++) { + if (setRangeOffsets(childNodes[i])) { + return true; // Fertig, wenn beide Punkte gesetzt sind + } + } + } + + return false; + }; + + setRangeOffsets(inputElement); + + selection.removeAllRanges(); + selection.addRange(range); + } +} diff --git a/projects/common/shared.module.ts b/projects/common/shared.module.ts index bdb97c6ff..1de52387c 100644 --- a/projects/common/shared.module.ts +++ b/projects/common/shared.module.ts @@ -32,6 +32,9 @@ import { PointerEventTooltipDirective } from 'common/components/tooltip/pointer- import { ClozeChildErrorMessage } from 'common/components/compound-elements/cloze/cloze-child-error-message'; import { TriggerComponent } from 'common/components/trigger/trigger.component'; import { ImageFullscreenDirective } from 'common/directives/image-fullscreen.directive'; +import { + AreaSegmentComponent +} from 'common/components/input-elements/text-area-math/area-segment.component'; import { TextComponent } from './components/text/text.component'; import { ButtonComponent } from './components/button/button.component'; import { TextFieldComponent } from './components/input-elements/text-field.component'; @@ -80,12 +83,11 @@ import { MathDegreesPipe } from './pipes/math-degrees.pipe'; import { ArrayIncludesPipe } from './pipes/array-includes.pipe'; import { SpinnerComponent } from './components/spinner/spinner.component'; import { GetValuePipe, MathFieldComponent } from './components/input-elements/math-field.component'; -import { MeasurePipe } from './pipes/measure.pipe'; import { TextImagePanelComponent } from './components/text-image-panel.component'; import { UnitDefErrorDialogComponent } from './components/unit-def-error-dialog.component'; import { MathTableComponent } from './components/input-elements/math-table.component'; -import { TextAreaMathComponent } from './components/input-elements/text-area-math.component'; +import { TextAreaMathComponent } from './components/input-elements/text-area-math/text-area-math.component'; import { DragImageComponent } from './components/input-elements/drop-list/drag-image.component'; import { DraggableDirective } from './components/input-elements/drop-list/draggable.directive'; @@ -114,7 +116,8 @@ import { DraggableDirective } from './components/input-elements/drop-list/dragga CdkConnectedOverlay, CdkOverlayOrigin, DraggableDirective, - ImageFullscreenDirective + ImageFullscreenDirective, + AreaSegmentComponent ], declarations: [ ButtonComponent, diff --git a/projects/editor/src/app/components/properties-panel/model-properties-tab/input-groups/preset-value-properties.component.ts b/projects/editor/src/app/components/properties-panel/model-properties-tab/input-groups/preset-value-properties.component.ts index c48407ff7..533e09653 100644 --- a/projects/editor/src/app/components/properties-panel/model-properties-tab/input-groups/preset-value-properties.component.ts +++ b/projects/editor/src/app/components/properties-panel/model-properties-tab/input-groups/preset-value-properties.component.ts @@ -40,10 +40,10 @@ import { CombinedProperties } from 'editor/src/app/components/properties-panel/e <ng-container *ngIf="combinedProperties.type === 'math-field'"> <mat-label>{{'preset' | translate }}</mat-label><br> - <aspect-mathlive-math-field [value]="$any(combinedProperties).value" + <aspect-math-input [value]="$any(combinedProperties).value" [enableModeSwitch]="true" (input)="updateModel.emit({property: 'value', value: $any($event.target).value })"> - </aspect-mathlive-math-field> + </aspect-math-input> </ng-container> ` }) diff --git a/projects/player/modules/logging/services/log.service.ts b/projects/player/modules/logging/services/log.service.ts index 651ce17b3..47ad6e147 100644 --- a/projects/player/modules/logging/services/log.service.ts +++ b/projects/player/modules/logging/services/log.service.ts @@ -6,7 +6,7 @@ export enum LogLevel { NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4 } providedIn: 'root' }) export class LogService { - static level: LogLevel = 0; + static level: LogLevel = 4; static error(...args: unknown[]): void { if (LogService.level >= LogLevel.ERROR) { diff --git a/projects/player/src/app/components/elements/element-group-selection/element-group-selection.component.ts b/projects/player/src/app/components/elements/element-group-selection/element-group-selection.component.ts index 9d1d56af8..f2e4e1505 100644 --- a/projects/player/src/app/components/elements/element-group-selection/element-group-selection.component.ts +++ b/projects/player/src/app/components/elements/element-group-selection/element-group-selection.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { UIElement } from 'common/models/elements/element'; -import { ElementGroupInterface, ElementGroupName } from '../../../models/element-group.interface'; import { UIElementType } from 'common/interfaces'; +import { ElementGroupInterface, ElementGroupName } from '../../../models/element-group.interface'; @Component({ selector: 'aspect-element-group-selection', @@ -13,13 +13,13 @@ export class ElementGroupSelectionComponent implements OnInit { @Input() pageIndex!: number; groups: ElementGroupInterface[] = [ - { name: 'textInputGroup', types: ['text-field', 'text-area', 'spell-correct'] }, + { name: 'textInputGroup', types: ['text-field', 'text-area', 'spell-correct', 'text-area-math'] }, { name: 'mediaPlayerGroup', types: ['audio', 'video'] }, { name: 'inputGroup', types: [ 'checkbox', 'slider', 'drop-list', 'radio', 'radio-group-images', - 'dropdown', 'hotspot-image', 'math-field', 'text-area-math' + 'dropdown', 'hotspot-image', 'math-field' ] }, { name: 'compoundGroup', types: ['cloze', 'likert', 'table'] }, diff --git a/projects/player/src/app/components/elements/input-group-element/input-group-element.component.html b/projects/player/src/app/components/elements/input-group-element/input-group-element.component.html index 99be60249..95562c52c 100644 --- a/projects/player/src/app/components/elements/input-group-element/input-group-element.component.html +++ b/projects/player/src/app/components/elements/input-group-element/input-group-element.component.html @@ -40,9 +40,4 @@ [parentForm]="form" [elementModel]="elementModel | cast: MathFieldElement"> </aspect-math-field> - <aspect-text-area-math *ngIf="elementModel.type === 'text-area-math'" - #elementComponent - [parentForm]="form" - [elementModel]="elementModel | cast: TextAreaMathElement"> - </aspect-text-area-math> </form> diff --git a/projects/player/src/app/components/elements/input-group-element/input-group-element.component.ts b/projects/player/src/app/components/elements/input-group-element/input-group-element.component.ts index 6782ee78e..53ddef17a 100644 --- a/projects/player/src/app/components/elements/input-group-element/input-group-element.component.ts +++ b/projects/player/src/app/components/elements/input-group-element/input-group-element.component.ts @@ -12,7 +12,6 @@ import { DropdownElement } from 'common/models/elements/input-elements/dropdown' import { InputElement } from 'common/models/elements/element'; import { HotspotImageElement } from 'common/models/elements/input-elements/hotspot-image'; import { MathFieldElement } from 'common/models/elements/input-elements/math-field'; -import { TextAreaMathElement } from 'common/models/elements/input-elements/text-area-math'; import { ValidationService } from '../../../services/validation.service'; import { ElementFormGroupDirective } from '../../../directives/element-form-group.directive'; import { ElementModelElementCodeMappingService } from '../../../services/element-model-element-code-mapping.service'; @@ -33,7 +32,6 @@ export class InputGroupElementComponent extends ElementFormGroupDirective implem DropdownElement!: DropdownElement; HotspotImageElement!: HotspotImageElement; MathFieldElement!: MathFieldElement; - TextAreaMathElement!: TextAreaMathElement; constructor( public unitStateService: UnitStateService, diff --git a/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.html b/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.html index d70d391e2..1485b28a8 100644 --- a/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.html +++ b/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.html @@ -24,6 +24,14 @@ (onKeyDown)="detectHardwareKeyboard(elementModel); checkInputLimitation($event, elementModel)" (focusChanged)="toggleKeyInput($event, elementComponent)"> </aspect-spell-correct> + <aspect-text-area-math + *ngIf="elementModel.type === 'text-area-math'" + #elementComponent + [parentForm]="form" + [elementModel]="elementModel | cast: TextAreaMathElement" + (onKeyDown)="detectHardwareKeyboard(elementModel)" + (focusChanged)="toggleKeyInput($event, elementComponent)"> + </aspect-text-area-math> </form> <aspect-floating-keypad diff --git a/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.ts b/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.ts index 8bee267e3..e877382e9 100644 --- a/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.ts +++ b/projects/player/src/app/components/elements/text-input-group-element/text-input-group-element.component.ts @@ -6,6 +6,7 @@ import { ElementComponent } from 'common/directives/element-component.directive' import { TextAreaElement } from 'common/models/elements/input-elements/text-area'; import { TextFieldElement } from 'common/models/elements/input-elements/text-field'; import { SpellCorrectElement } from 'common/models/elements/input-elements/spell-correct'; +import { TextAreaMathElement } from 'common/models/elements/input-elements/text-area-math'; import { InputElement } from 'common/models/elements/element'; import { TextInputGroupDirective } from 'player/src/app/directives/text-input-group.directive'; import { DeviceService } from '../../../services/device.service'; @@ -27,6 +28,7 @@ export class TextInputGroupElementComponent TextAreaElement!: TextAreaElement; TextFieldElement!: TextFieldElement; SpellCorrectElement!: SpellCorrectElement; + TextAreaMathElement!: TextAreaMathElement; constructor( public keyboardService: KeyboardService, diff --git a/projects/player/src/app/components/layouts/player-layout/player-layout.component.ts b/projects/player/src/app/components/layouts/player-layout/player-layout.component.ts index 030208cca..32d3e432a 100644 --- a/projects/player/src/app/components/layouts/player-layout/player-layout.component.ts +++ b/projects/player/src/app/components/layouts/player-layout/player-layout.component.ts @@ -64,7 +64,7 @@ export class PlayerLayoutComponent implements OnDestroy { } setIsKeypadToggling(): void { - this.isKeypadToggling = setTimeout(() => { + this.isKeypadToggling = window.setTimeout(() => { clearTimeout(this.isKeypadToggling); this.isKeypadToggling = 0; this.isKeypadAnimationDisabled = false; @@ -72,7 +72,7 @@ export class PlayerLayoutComponent implements OnDestroy { } setIsKeyboardToggling(): void { - this.isKeyboardToggling = setTimeout(() => { + this.isKeyboardToggling = window.setTimeout(() => { clearTimeout(this.isKeyboardToggling); this.isKeyboardToggling = 0; this.isKeyboardAnimationDisabled = false; diff --git a/projects/player/src/app/directives/text-input-group.directive.ts b/projects/player/src/app/directives/text-input-group.directive.ts index 8d87f616e..2c02fed1d 100644 --- a/projects/player/src/app/directives/text-input-group.directive.ts +++ b/projects/player/src/app/directives/text-input-group.directive.ts @@ -8,11 +8,13 @@ import { DeviceService } from 'player/src/app/services/device.service'; import { KeypadService } from 'player/src/app/services/keypad.service'; import { KeyboardService } from 'player/src/app/services/keyboard.service'; import { TextInputComponentType } from 'player/src/app/models/text-input-component.type'; +import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math/text-area-math.component'; +import { RangeSelectionService } from 'common/services/range-selection-service'; @Directive() export abstract class TextInputGroupDirective extends ElementFormGroupDirective implements OnDestroy { isKeypadOpen: boolean = false; - inputElement!: HTMLTextAreaElement | HTMLInputElement; + inputElement!: HTMLTextAreaElement | HTMLInputElement | HTMLElement; keypadEnterKeySubscription!: Subscription; keypadDeleteCharactersSubscription!: Subscription; @@ -32,7 +34,7 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective } async toggleKeyInput(focusedTextInput: { inputElement: HTMLElement; focused: boolean }, - elementComponent: TextInputComponentType): Promise<void> { + elementComponent: TextInputComponentType | TextAreaMathComponent): Promise<void> { const promises: Promise<boolean>[] = []; if (elementComponent.elementModel.showSoftwareKeyboard && !elementComponent.elementModel.readOnly) { promises.push(this.keyboardService @@ -42,22 +44,23 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective promises.push(this.keypadService.toggleAsync(focusedTextInput, elementComponent)); } if (promises.length) { - await Promise.all(promises).then(() => { - if (this.keyboardService.isOpen) { - this.subscribeForKeyboardEvents(elementComponent.elementModel, elementComponent); - } else { - this.unsubscribeFromKeyboardEvents(); - } - if (this.keypadService.isOpen) { - this.subscribeForKeypadEvents(elementComponent.elementModel, elementComponent); - } else { - this.unsubscribeFromKeypadEvents(); - } - this.isKeypadOpen = this.keypadService.isOpen; - if (this.keyboardService.isOpen || this.keypadService.isOpen) { - this.setInputElement(focusedTextInput.inputElement); - } - }); + await Promise.all(promises) + .then(() => { + if (this.keyboardService.isOpen) { + this.subscribeForKeyboardEvents(elementComponent.elementModel, elementComponent); + } else { + this.unsubscribeFromKeyboardEvents(); + } + if (this.keypadService.isOpen) { + this.subscribeForKeypadEvents(elementComponent.elementModel, elementComponent); + } else { + this.unsubscribeFromKeypadEvents(); + } + this.isKeypadOpen = this.keypadService.isOpen; + if (this.keyboardService.isOpen || this.keypadService.isOpen) { + this.inputElement = this.getInputElement(focusedTextInput.inputElement); + } + }); } } @@ -108,15 +111,20 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective if (this.keyboardDeleteCharactersSubscription) this.keyboardDeleteCharactersSubscription.unsubscribe(); } - private setInputElement(inputElement: HTMLElement): void { - this.inputElement = this.elementModel.type === 'text-area' ? - inputElement as HTMLTextAreaElement : - inputElement as HTMLInputElement; + private getInputElement(inputElement: HTMLElement): HTMLTextAreaElement | HTMLInputElement | HTMLElement { + switch (this.elementModel.type) { + case 'text-area': + return inputElement as HTMLTextAreaElement; + case 'text-area-math': + return inputElement as HTMLElement; + default: + return inputElement as HTMLInputElement; + } } private select(direction: string): void { let lastBreak = 0; - const inputValueKeys = this.inputElement.value.split(''); + const inputValueKeys = this.getInputElementValue().split(''); const lineBreaks = inputValueKeys .reduce( (previousValue: number[][], @@ -132,17 +140,16 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective } return previousValue; }, []); - const selectionStart = this.inputElement.selectionStart || 0; - const selectionEnd = this.inputElement.selectionEnd || 0; - let newSelection = selectionStart; - + const selectionStart = this.getSelection().start; + const selectionEnd = this.getSelection().end; + let newSelection: number; switch (direction) { case 'ArrowLeft': { - newSelection -= 1; + newSelection = selectionStart === selectionEnd ? selectionStart - 1 : selectionStart; break; } case 'ArrowRight': { - newSelection += 1; + newSelection = selectionStart === selectionEnd ? selectionEnd + 1 : selectionEnd; break; } case 'ArrowUp': { @@ -170,15 +177,15 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective newSelection = selectionStart; } } - this.inputElement.setSelectionRange(newSelection, newSelection); + this.setSelection(newSelection, newSelection); } private enterKey(key: string, elementModel: UIElement, elementComponent: ElementComponent): void { if (!(elementModel.maxLength && elementModel.isLimitedToMaxLength && - this.inputElement.value.length === elementModel.maxLength)) { - const selectionStart = this.inputElement.selectionStart || 0; - const selectionEnd = this.inputElement.selectionEnd || 0; + this.getInputElementValue().length === elementModel.maxLength)) { + const selectionStart = this.getSelection().start; + const selectionEnd = this.getSelection().end; const newSelection = selectionStart ? selectionStart + 1 : 1; this.insert({ selectionStart, selectionEnd, newSelection, key @@ -187,17 +194,17 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective } private deleteCharacters(backspace: boolean, elementComponent: ElementComponent): void { - let selectionStart = this.inputElement.selectionStart || 0; - let selectionEnd = this.inputElement.selectionEnd || 0; - if (backspace && selectionEnd > 0) { - if (selectionStart === selectionEnd) { + let selectionStart = this.getSelection().start; + let selectionEnd = this.getSelection().end; + if (backspace) { + if (selectionStart === selectionEnd && selectionEnd > 0) { selectionStart -= 1; } this.insert({ selectionStart, selectionEnd, newSelection: selectionStart, key: '' - }, elementComponent); + }, elementComponent, (backspace && selectionEnd === 0) || undefined); } - if (!backspace && selectionEnd <= this.inputElement.value.length) { + if (!backspace && selectionEnd <= this.getInputElementValue().length) { if (selectionStart === selectionEnd) { selectionEnd += 1; } @@ -207,17 +214,53 @@ export abstract class TextInputGroupDirective extends ElementFormGroupDirective } } + private getSelection(): { start: number; end: number } { + if (this.inputElement instanceof HTMLInputElement || this.inputElement instanceof HTMLTextAreaElement) { + return { start: this.inputElement.selectionStart || 0, end: this.inputElement.selectionEnd || 0 }; + } + return this.getSelectionRange(); + } + + private getSelectionRange(): { start: number; end: number } { + const range = RangeSelectionService.getRange(); + if (!range) return { start: 0, end: 0 }; + return RangeSelectionService.getSelectionRange(range, this.inputElement); + } + + private getInputElementValue(): string { + if (this.inputElement instanceof HTMLInputElement || this.inputElement instanceof HTMLTextAreaElement) { + return this.inputElement.value; + } + return this.inputElement.textContent || ''; + } + private insert(keyAtPosition: { selectionStart: number; selectionEnd: number; newSelection: number; key: string - }, elementComponent: ElementComponent): void { - const startText = this.inputElement.value.substring(0, keyAtPosition.selectionStart); - const endText = this.inputElement.value.substring(keyAtPosition.selectionEnd); - (elementComponent as FormElementComponent).elementFormControl - .setValue(startText + keyAtPosition.key + endText); - this.inputElement.setSelectionRange(keyAtPosition.newSelection, keyAtPosition.newSelection); + }, elementComponent: ElementComponent, backSpaceAtFirstPosition?: boolean): void { + const startText = this.getStartText(keyAtPosition.selectionStart); + const endText = this.getEndText(keyAtPosition.selectionEnd); + (elementComponent as FormElementComponent) + .setElementValue(startText + keyAtPosition.key + endText, backSpaceAtFirstPosition || undefined); + this.setSelection(keyAtPosition.newSelection, keyAtPosition.newSelection, backSpaceAtFirstPosition || undefined); + } + + private getStartText(startPosition: number): string { + return this.getInputElementValue().substring(0, startPosition); + } + + private getEndText(endPosition: number): string { + return this.getInputElementValue().substring(endPosition); + } + + setSelection(start: number, end: number, backSpaceAtFirstPosition?: boolean): void { + if (this.inputElement instanceof HTMLInputElement || this.inputElement instanceof HTMLTextAreaElement) { + this.inputElement.setSelectionRange(start, end); + } else if (!backSpaceAtFirstPosition) { + setTimeout(() => RangeSelectionService.setSelectionRange(this.inputElement, start, end)); + } } ngOnDestroy(): void { diff --git a/projects/player/src/app/services/element-model-element-code-mapping.service.ts b/projects/player/src/app/services/element-model-element-code-mapping.service.ts index e53144c95..0c424590b 100644 --- a/projects/player/src/app/services/element-model-element-code-mapping.service.ts +++ b/projects/player/src/app/services/element-model-element-code-mapping.service.ts @@ -29,6 +29,7 @@ export class ElementModelElementCodeMappingService { mapToElementModelValue(elementCodeValue: ResponseValueType | undefined, elementModel: UIElement): InputElementValue { switch (elementModel.type) { + case 'text-area-math': case 'math-table': return (elementCodeValue !== undefined) ? JSON.parse(elementCodeValue as string) : @@ -89,6 +90,7 @@ export class ElementModelElementCodeMappingService { return elementModelValue as string; case 'image': return elementModelValue as boolean; + case 'text-area-math': case 'math-table': return JSON.stringify(elementModelValue); case 'drop-list': diff --git a/projects/player/src/app/services/input-service.ts b/projects/player/src/app/services/input-service.ts index d75e6244d..123c0c069 100644 --- a/projects/player/src/app/services/input-service.ts +++ b/projects/player/src/app/services/input-service.ts @@ -2,13 +2,14 @@ import { EventEmitter, Injectable, Output } from '@angular/core'; import { TextInputComponentType } from 'player/src/app/models/text-input-component.type'; import { MathTableComponent } from 'common/components/input-elements/math-table.component'; import { InputAssistancePreset } from 'common/interfaces'; +import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math/text-area-math.component'; @Injectable({ providedIn: 'root' }) export abstract class InputService { preset: InputAssistancePreset = null; - elementComponent!: TextInputComponentType | MathTableComponent; + elementComponent!: TextInputComponentType | MathTableComponent | TextAreaMathComponent; inputElement!: HTMLTextAreaElement | HTMLInputElement | HTMLElement; isOpen: boolean = false; @@ -19,7 +20,7 @@ export abstract class InputService { setCurrentKeyInputElement( focusedElement: HTMLElement, - elementComponent: TextInputComponentType | MathTableComponent + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent ): void { this.inputElement = focusedElement; this.elementComponent = elementComponent; diff --git a/projects/player/src/app/services/keyboard.service.ts b/projects/player/src/app/services/keyboard.service.ts index 540a2c959..d48a7e508 100644 --- a/projects/player/src/app/services/keyboard.service.ts +++ b/projects/player/src/app/services/keyboard.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { TextInputComponentType } from 'player/src/app/models/text-input-component.type'; import { MathTableComponent } from 'common/components/input-elements/math-table.component'; +import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math/text-area-math.component'; import { InputService } from './input-service'; @Injectable({ @@ -10,7 +11,7 @@ export class KeyboardService extends InputService { addInputAssistanceToKeyboard: boolean = false; async toggleAsync(focusedTextInput: { inputElement: HTMLElement; focused: boolean }, - elementComponent: TextInputComponentType | MathTableComponent, + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent, isMobileWithoutHardwareKeyboard: boolean): Promise<boolean> { this.willToggle.emit(this.isOpen); return new Promise(resolve => { @@ -22,7 +23,7 @@ export class KeyboardService extends InputService { } private toggle(focusedTextInput: { inputElement: HTMLElement; focused: boolean }, - elementComponent: TextInputComponentType | MathTableComponent, + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent, isMobileWithoutHardwareKeyboard: boolean): boolean { if (focusedTextInput.focused && isMobileWithoutHardwareKeyboard) { this.open(focusedTextInput.inputElement, elementComponent); @@ -32,7 +33,8 @@ export class KeyboardService extends InputService { return this.isOpen; } - open(inputElement: HTMLElement, elementComponent: TextInputComponentType | MathTableComponent): void { + open(inputElement: HTMLElement, + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent): void { this.addInputAssistanceToKeyboard = elementComponent.elementModel.addInputAssistanceToKeyboard; this.preset = elementComponent.elementModel.inputAssistancePreset; this.setCurrentKeyInputElement(inputElement, elementComponent); diff --git a/projects/player/src/app/services/keypad.service.ts b/projects/player/src/app/services/keypad.service.ts index f278515b7..86220e5ee 100644 --- a/projects/player/src/app/services/keypad.service.ts +++ b/projects/player/src/app/services/keypad.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { TextInputComponentType } from 'player/src/app/models/text-input-component.type'; import { MathTableComponent } from 'common/components/input-elements/math-table.component'; +import { TextAreaMathComponent } from 'common/components/input-elements/text-area-math/text-area-math.component'; import { InputService } from './input-service'; @Injectable({ @@ -10,7 +11,8 @@ export class KeypadService extends InputService { position: 'floating' | 'right' = 'floating'; async toggleAsync(focusedTextInput: { inputElement: HTMLElement; focused: boolean }, - elementComponent: TextInputComponentType | MathTableComponent): Promise<boolean> { + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent + ): Promise<boolean> { this.willToggle.emit(this.isOpen); return new Promise(resolve => { setTimeout(() => resolve(this.toggle(focusedTextInput, elementComponent)), 100); @@ -18,7 +20,7 @@ export class KeypadService extends InputService { } private toggle(focusedTextInput: { inputElement: HTMLElement; focused: boolean }, - elementComponent: TextInputComponentType | MathTableComponent): boolean { + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent): boolean { if (focusedTextInput.focused) { this.open(focusedTextInput.inputElement, elementComponent); } else { @@ -28,7 +30,7 @@ export class KeypadService extends InputService { } open(inputElement: HTMLElement, - elementComponent: TextInputComponentType | MathTableComponent): + elementComponent: TextInputComponentType | MathTableComponent | TextAreaMathComponent): void { this.preset = elementComponent.elementModel.inputAssistancePreset; this.position = elementComponent.elementModel.inputAssistancePosition; -- GitLab