From 7f1184be0722bcedd6e8e309b6335e7677678486 Mon Sep 17 00:00:00 2001 From: jojohoch <joachim.hoch@iqb.hu-berlin.de> Date: Fri, 29 Oct 2021 10:14:11 +0200 Subject: [PATCH] [player] Implement intersection detection for compound element children * Add IntersectionService * Add CompoundElementComponent as parent for LikertComponent * Inject ElementRef to common ElementComponent * Rename players' ElementComponent to ElementContainerComponent --- .../common/element-component.directive.ts | 8 +- .../compound-element.directive.ts | 21 ++++ .../compound-elements/likert.component.ts | 18 +-- projects/player/src/app/app.module.ts | 4 +- ...nt.css => element-container.component.css} | 0 ....html => element-container.component.html} | 0 ...nent.ts => element-container.component.ts} | 114 +++++++++--------- .../src/app/components/page/page.component.ts | 6 +- .../components/section/section.component.html | 18 +-- .../components/section/section.component.ts | 6 - .../intersection-detection.directive.ts | 15 +-- .../src/app/services/intersection.service.ts | 47 ++++++++ .../src/app/services/unit-state.service.ts | 55 ++++++++- 13 files changed, 207 insertions(+), 105 deletions(-) create mode 100644 projects/common/element-components/compound-elements/compound-element.directive.ts rename projects/player/src/app/components/element/{element.component.css => element-container.component.css} (100%) rename projects/player/src/app/components/element/{element.component.html => element-container.component.html} (100%) rename projects/player/src/app/components/element/{element.component.ts => element-container.component.ts} (73%) create mode 100644 projects/player/src/app/services/intersection.service.ts diff --git a/projects/common/element-component.directive.ts b/projects/common/element-component.directive.ts index 0ae755a49..5b864a46c 100644 --- a/projects/common/element-component.directive.ts +++ b/projects/common/element-component.directive.ts @@ -1,9 +1,15 @@ import { - Directive + Directive, ElementRef } from '@angular/core'; import { UIElement } from './models/uI-element'; @Directive() export abstract class ElementComponent { abstract elementModel: UIElement; + + constructor(private elementRef: ElementRef) {} + + get domElement(): Element { + return this.elementRef.nativeElement; + } } diff --git a/projects/common/element-components/compound-elements/compound-element.directive.ts b/projects/common/element-components/compound-elements/compound-element.directive.ts new file mode 100644 index 000000000..7a0aa4ab1 --- /dev/null +++ b/projects/common/element-components/compound-elements/compound-element.directive.ts @@ -0,0 +1,21 @@ +import { + AfterViewInit, + Directive, EventEmitter, Output, QueryList +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { ElementComponent } from '../../element-component.directive'; +import { InputElement } from '../../models/uI-element'; + +@Directive({ selector: 'app-compound-element' }) + +export abstract class CompoundElementComponent implements AfterViewInit { + @Output() childrenAdded = new EventEmitter<QueryList<ElementComponent>>(); + compoundChildren!: QueryList<ElementComponent>; + parentForm!: FormGroup; + + ngAfterViewInit(): void { + this.childrenAdded.emit(this.compoundChildren); + } + + abstract getFormElementModelChildren(): InputElement[]; +} diff --git a/projects/common/element-components/compound-elements/likert.component.ts b/projects/common/element-components/compound-elements/likert.component.ts index f9a69a49e..90cf1c9e2 100644 --- a/projects/common/element-components/compound-elements/likert.component.ts +++ b/projects/common/element-components/compound-elements/likert.component.ts @@ -1,8 +1,12 @@ -import { Component, EventEmitter, Output } from '@angular/core'; +import { + Component, EventEmitter, Output, QueryList, ViewChildren +} from '@angular/core'; import { FormGroup } from '@angular/forms'; import { LikertElement } from '../../models/compound-elements/likert-element'; -import { InputElementValue, ValueChangeElement } from '../../models/uI-element'; +import { ValueChangeElement } from '../../models/uI-element'; import { LikertElementRow } from '../../models/compound-elements/likert-element-row'; +import { LikertRadioButtonGroupComponent } from './likert-radio-button-group.component'; +import { CompoundElementComponent } from './compound-element.directive'; @Component({ selector: 'app-likert', @@ -65,15 +69,13 @@ import { LikertElementRow } from '../../models/compound-elements/likert-element- '::ng-deep app-likert mat-radio-button span.mat-radio-container {left: calc(50% - 10px)}' ] }) -export class LikertComponent { +export class LikertComponent extends CompoundElementComponent { @Output() formValueChanged = new EventEmitter<ValueChangeElement>(); + @ViewChildren(LikertRadioButtonGroupComponent) compoundChildren!: QueryList<LikertRadioButtonGroupComponent>; elementModel!: LikertElement; parentForm!: FormGroup; - getChildElementValues(): { id: string, value: InputElementValue }[] { - return this.elementModel.questions - .map((question: LikertElementRow): { id: string, value: InputElementValue } => ( - { id: question.id, value: question.value } - )); + getFormElementModelChildren(): LikertElementRow[] { + return this.elementModel.questions; } } diff --git a/projects/player/src/app/app.module.ts b/projects/player/src/app/app.module.ts index 0565961ea..59da62818 100644 --- a/projects/player/src/app/app.module.ts +++ b/projects/player/src/app/app.module.ts @@ -9,7 +9,7 @@ import { AppComponent } from './app.component'; import { PageComponent } from './components/page/page.component'; import { SectionComponent } from './components/section/section.component'; import { SharedModule } from '../../../common/shared.module'; -import { ElementComponent } from './components/element/element.component'; +import { ElementContainerComponent } from './components/element/element-container.component'; import { UnitStateComponent } from './components/unit-state/unit-state.component'; import { PlayerStateComponent } from './components/player-state/player-state.component'; import { PlayerTranslateLoader } from './classes/player-translate-loader'; @@ -30,7 +30,7 @@ import { NumbersAndOperatorsKeyboardComponent } AppComponent, PageComponent, SectionComponent, - ElementComponent, + ElementContainerComponent, UnitStateComponent, PlayerStateComponent, LayoutComponent, diff --git a/projects/player/src/app/components/element/element.component.css b/projects/player/src/app/components/element/element-container.component.css similarity index 100% rename from projects/player/src/app/components/element/element.component.css rename to projects/player/src/app/components/element/element-container.component.css diff --git a/projects/player/src/app/components/element/element.component.html b/projects/player/src/app/components/element/element-container.component.html similarity index 100% rename from projects/player/src/app/components/element/element.component.html rename to projects/player/src/app/components/element/element-container.component.html diff --git a/projects/player/src/app/components/element/element.component.ts b/projects/player/src/app/components/element/element-container.component.ts similarity index 73% rename from projects/player/src/app/components/element/element.component.ts rename to projects/player/src/app/components/element/element-container.component.ts index 2556a170a..74358834c 100644 --- a/projects/player/src/app/components/element/element.component.ts +++ b/projects/player/src/app/components/element/element-container.component.ts @@ -1,6 +1,5 @@ import { - Component, OnInit, Input, ComponentFactoryResolver, - ViewChild, ViewContainerRef + Component, OnInit, Input, ComponentFactoryResolver, ViewChild, ViewContainerRef, QueryList } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ValidatorFn @@ -16,19 +15,21 @@ import { UnitStateService } from '../../services/unit-state.service'; import { MarkingService } from '../../services/marking.service'; import { InputElement, - InputElementValue, UIElement, ValueChangeElement } from '../../../../../common/models/uI-element'; import { TextFieldElement } from '../../../../../common/models/text-field-element'; import { FormElementComponent } from '../../../../../common/form-element-component.directive'; +import { ElementComponent } from '../../../../../common/element-component.directive'; +import { CompoundElementComponent } + from '../../../../../common/element-components/compound-elements/compound-element.directive'; @Component({ - selector: 'app-element', - templateUrl: './element.component.html', - styleUrls: ['./element.component.css'] + selector: 'app-element-container', + templateUrl: './element-container.component.html', + styleUrls: ['./element-container.component.css'] }) -export class ElementComponent implements OnInit { +export class ElementContainerComponent implements OnInit { @Input() elementModel!: UIElement; @Input() parentForm!: FormGroup; @Input() parentArrayIndex!: number; @@ -53,24 +54,23 @@ export class ElementComponent implements OnInit { const elementComponentFactory = ElementFactory.getComponentFactory(this.elementModel.type, this.componentFactoryResolver); const elementComponent = this.elementComponentContainer.createComponent(elementComponentFactory).instance; - elementComponent.elementModel = this.elementModel; - - const unitStateElementCode = this.unitStateService.getUnitStateElement(this.elementModel.id); - if (unitStateElementCode && unitStateElementCode.value !== undefined) { - switch (this.elementModel.type) { - case 'text': - elementComponent.elementModel.text = unitStateElementCode.value; - break; - case 'video': - case 'audio': - elementComponent.elementModel.playbackTime = unitStateElementCode.value; - break; - default: - elementComponent.elementModel.value = unitStateElementCode.value; - } + elementComponent.elementModel = this.unitStateService.restoreUnitStateValue(this.elementModel); + + if (elementComponent.domElement) { + this.unitStateService.registerElement(elementComponent.elementModel, elementComponent.domElement); } - this.unitStateService.registerElement(elementComponent.elementModel.id, elementComponent.elementModel.value); + if (elementComponent.childrenAdded) { + elementComponent.childrenAdded + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((children: QueryList<ElementComponent>) => { + children.forEach(child => { + if (child.domElement) { + this.unitStateService.registerElement(child.elementModel, child.domElement); + } + }); + }); + } if (elementComponent.applySelection) { elementComponent.applySelection @@ -88,47 +88,36 @@ export class ElementComponent implements OnInit { }); } - if (elementComponent instanceof FormElementComponent || this.elementModel.type === 'likert') { - const elementForm = this.formBuilder.group({}); + if (elementComponent.formValueChanged) { + elementComponent.formValueChanged + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((changeElement: ValueChangeElement) => { + this.unitStateService.changeElementValue(changeElement); + }); + } + + if (elementComponent.setValidators) { + elementComponent.setValidators + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((validators: ValidatorFn[]) => { + this.formService.setValidators({ + id: this.elementModel.id, + validators: validators, + formGroup: elementForm + }); + }); + } + + const elementForm = this.formBuilder.group({}); + if (elementComponent instanceof FormElementComponent) { elementComponent.parentForm = elementForm; this.registerFormGroup(elementForm); - this.formService.registerFormControl({ id: this.elementModel.id, formControl: new FormControl((this.elementModel as InputElement).value), formGroup: elementForm }); - if (this.elementModel.type === 'likert') { - elementComponent.getChildElementValues() - .forEach((element: { id: string, value: InputElementValue }) => { - this.unitStateService.registerElement(element.id, element.value); - this.formService.registerFormControl({ - id: element.id, - formControl: new FormControl((element as InputElement).value), - formGroup: elementForm - }); - }); - } - - if (elementComponent.setValidators) { - elementComponent.setValidators - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((validators: ValidatorFn[]) => { - this.formService.setValidators({ - id: this.elementModel.id, - validators: validators, - formGroup: elementForm - }); - }); - } - - elementComponent.formValueChanged - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((changeElement: ValueChangeElement) => { - this.unitStateService.changeElementValue(changeElement); - }); - if (this.elementModel.inputAssistancePreset !== 'none' && (this.elementModel.type === 'text-field' || this.elementModel.type === 'text-area')) { this.keyboardLayout = (this.elementModel as TextFieldElement).inputAssistancePreset; @@ -138,7 +127,18 @@ export class ElementComponent implements OnInit { this.initEventsForKeyboard(elementComponent as TextAreaComponent); } } - } // no else + } else if (elementComponent instanceof CompoundElementComponent) { + elementComponent.parentForm = elementForm; + elementComponent.getFormElementModelChildren() + .forEach((element: InputElement) => { + this.registerFormGroup(elementForm); + this.formService.registerFormControl({ + id: element.id, + formControl: new FormControl(element.value), + formGroup: elementForm + }); + }); + } } private registerFormGroup(elementForm: FormGroup): void { diff --git a/projects/player/src/app/components/page/page.component.ts b/projects/player/src/app/components/page/page.component.ts index 884707906..e2fe8f023 100644 --- a/projects/player/src/app/components/page/page.component.ts +++ b/projects/player/src/app/components/page/page.component.ts @@ -40,11 +40,11 @@ export class PageComponent implements OnInit { }); } - onIntersection(detection: { detectionType: 'top' | 'bottom' | 'full', id: string }): void { - if (detection.detectionType === 'bottom') { + onIntersection(detectionType: 'top' | 'bottom'): void { + if (detectionType === 'bottom') { this.unitStateService.addPresentedPage(this.index); } - if (detection.detectionType === 'top' || this.isLastPage) { + if (detectionType === 'top' || this.isLastPage) { this.selectedIndexChange.emit(this.index); } } diff --git a/projects/player/src/app/components/section/section.component.html b/projects/player/src/app/components/section/section.component.html index cfb69b543..0a6aea133 100644 --- a/projects/player/src/app/components/section/section.component.html +++ b/projects/player/src/app/components/section/section.component.html @@ -2,12 +2,7 @@ <ng-template #staticElements> <ng-container *ngFor="let element of section.elements; let i = index"> - <app-element - appIntersectionDetection - detectionType="full" - [intersectionContainer]="document" - [id]="element.id" - (intersecting)="onIntersection($event)" + <app-element-container [style.display]="'block'" [style.overflow]="'auto'" [style.width.px]="element.width" @@ -18,7 +13,7 @@ [elementModel]="element" [parentForm]="sectionForm" [parentArrayIndex]="i"> - </app-element> + </app-element-container> </ng-container> </ng-template> @@ -29,12 +24,7 @@ [style.grid-auto-columns]="section.autoColumnSize ? 'auto' : undefined" [style.grid-auto-rows]="section.autoRowSize ? 'auto' : undefined"> <ng-container *ngFor="let element of section.elements; let i = index"> - <app-element - appIntersectionDetection - detectionType="full" - [intersectionContainer]="document" - [id]="element.id" - (intersecting)="onIntersection($event)" + <app-element-container [style.min-width.px]="element.width" [style.min-height.px]="element.height" [style.margin-left.px]="element.marginLeft" @@ -48,7 +38,7 @@ [elementModel]="element" [parentForm]="sectionForm" [parentArrayIndex]="i"> - </app-element> + </app-element-container> </ng-container> </div> </ng-template> diff --git a/projects/player/src/app/components/section/section.component.ts b/projects/player/src/app/components/section/section.component.ts index 785c6d2f8..4200ed318 100644 --- a/projects/player/src/app/components/section/section.component.ts +++ b/projects/player/src/app/components/section/section.component.ts @@ -5,7 +5,6 @@ import { FormBuilder, FormGroup } from '@angular/forms'; import { DOCUMENT } from '@angular/common'; import { FormService } from '../../services/form.service'; import { Section } from '../../../../../common/models/section'; -import { UnitStateService } from '../../services/unit-state.service'; @Component({ selector: 'app-section', @@ -20,7 +19,6 @@ export class SectionComponent implements OnInit { constructor(private formService: FormService, private formBuilder: FormBuilder, - private unitStateService: UnitStateService, @Inject(DOCUMENT) public document: Document) { } @@ -35,8 +33,4 @@ export class SectionComponent implements OnInit { parentArrayIndex: this.parentArrayIndex }); } - - onIntersection(detection: { detectionType: 'top' | 'bottom' | 'full', id: string }): void { - this.unitStateService.changeElementStatus({ id: detection.id, status: 'DISPLAYED' }); - } } diff --git a/projects/player/src/app/directives/intersection-detection.directive.ts b/projects/player/src/app/directives/intersection-detection.directive.ts index 0542cc788..1f64c05f8 100644 --- a/projects/player/src/app/directives/intersection-detection.directive.ts +++ b/projects/player/src/app/directives/intersection-detection.directive.ts @@ -6,10 +6,9 @@ import { selector: '[appIntersectionDetection]' }) export class IntersectionDetectionDirective implements OnInit, OnDestroy { - @Input() detectionType!: 'top' | 'bottom' | 'full'; - @Input() id!: string; - @Output() intersecting = new EventEmitter<{ detectionType: 'top' | 'bottom' | 'full', id: string }>(); - @Input() intersectionContainer!: HTMLElement | Document; + @Input() detectionType!: 'top' | 'bottom'; + @Output() intersecting = new EventEmitter<'top' | 'bottom'>(); + @Input() intersectionContainer!: HTMLElement; intersectionObserver!: IntersectionObserver; @@ -18,11 +17,7 @@ export class IntersectionDetectionDirective implements OnInit, OnDestroy { constructor(private elementRef: ElementRef) {} ngOnInit(): void { - if (this.detectionType === 'top') { - this.constraint = '0px 0px -95% 0px'; - } else { - this.constraint = this.detectionType === 'full' ? '0px 0px 0px 0px' : '-95% 0px 0px 0px'; - } + this.constraint = this.detectionType === 'top' ? '0px 0px -95% 0px' : '-95% 0px 0px 0px'; this.initIntersectionObserver(); } @@ -30,7 +25,7 @@ export class IntersectionDetectionDirective implements OnInit, OnDestroy { this.intersectionObserver = new IntersectionObserver( (entries: IntersectionObserverEntry[]): void => entries.forEach(entry => { if (entry.isIntersecting) { - this.intersecting.emit({ detectionType: this.detectionType, id: this.id }); + this.intersecting.emit(this.detectionType); } }), { root: this.intersectionContainer, diff --git a/projects/player/src/app/services/intersection.service.ts b/projects/player/src/app/services/intersection.service.ts new file mode 100644 index 000000000..df382b5b0 --- /dev/null +++ b/projects/player/src/app/services/intersection.service.ts @@ -0,0 +1,47 @@ +import { + EventEmitter, Inject, Injectable, Output +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +@Injectable({ + providedIn: 'root' +}) +export class IntersectionService { + intersectionObserver!: IntersectionObserver; + elements: { id: string, element: Element }[] = []; + @Output() intersecting = new EventEmitter<string>(); + + constructor(@Inject(DOCUMENT) private document: Document) { + this.initIntersectionObserver(); + } + + private initIntersectionObserver(): void { + this.intersectionObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]): void => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.intersectionDetected(entry.target); + } + }); + }, { + root: document, + rootMargin: '0px 0px 0px 0px' + } + ); + } + + observe(id: string, element: Element): void { + this.elements.push({ id, element }); + this.intersectionObserver.observe(element); + } + + private intersectionDetected(element: Element):void { + const intersectedElementIndex = this.elements.findIndex(e => e.element === element); + if (intersectedElementIndex > -1) { + const intersectedElement = this.elements[intersectedElementIndex]; + this.intersecting.emit(intersectedElement.id); + this.intersectionObserver.unobserve(intersectedElement.element); + this.elements.splice(intersectedElementIndex, 1); + } + } +} diff --git a/projects/player/src/app/services/unit-state.service.ts b/projects/player/src/app/services/unit-state.service.ts index 665a1feb8..5fe370dc8 100644 --- a/projects/player/src/app/services/unit-state.service.ts +++ b/projects/player/src/app/services/unit-state.service.ts @@ -6,7 +6,13 @@ import { UnitStateElementCodeStatus, UnitStateElementCodeStatusValue } from '../models/verona'; -import { InputElementValue, ValueChangeElement } from '../../../../common/models/uI-element'; +import { + InputElement, InputElementValue, UIElement, ValueChangeElement +} from '../../../../common/models/uI-element'; +import { TextElement } from '../../../../common/models/text-element'; +import { VideoElement } from '../../../../common/models/video-element'; +import { AudioElement } from '../../../../common/models/audio-element'; +import { IntersectionService } from './intersection.service'; @Injectable({ providedIn: 'root' @@ -16,7 +22,9 @@ export class UnitStateService { private _unitStateElementCodeChanged = new Subject<UnitStateElementCode>(); unitStateElementCodes!: UnitStateElementCode[]; - getUnitStateElement(id: string): UnitStateElementCode | undefined { + constructor(private intersectionService: IntersectionService) {} + + private getUnitStateElement(id: string): UnitStateElementCode | undefined { return this.unitStateElementCodes .find((elementCode: UnitStateElementCode): boolean => elementCode.id === id); } @@ -48,8 +56,31 @@ export class UnitStateService { return this._presentedPageAdded.asObservable(); } - registerElement(id: string, value: InputElementValue): void { - this.addUnitStateElementCode(id, value); + registerElement(elementModel: UIElement, element: Element): void { + this.initUnitStateValue(elementModel); + this.intersectionService.observe(elementModel.id, element); + this.intersectionService.intersecting + .subscribe((id: string) => { + this.changeElementStatus({ id: id, status: 'DISPLAYED' }); + }); + } + + restoreUnitStateValue(elementModel: UIElement): UIElement { + const unitStateElementCode = this.getUnitStateElement(elementModel.id); + if (unitStateElementCode && unitStateElementCode.value !== undefined) { + switch (elementModel.type) { + case 'text': + elementModel.text = unitStateElementCode.value; + break; + case 'video': + case 'audio': + elementModel.playbackTime = unitStateElementCode.value; + break; + default: + elementModel.value = unitStateElementCode.value; + } + } + return elementModel; } addPresentedPage(presentedPage: number): void { @@ -70,6 +101,22 @@ export class UnitStateService { this.setUnitStateElementCodeStatus(elementStatus.id, elementStatus.status); } + private initUnitStateValue(elementModel: UIElement): void { + switch (elementModel.type) { + case 'text': + this.addUnitStateElementCode(elementModel.id, (elementModel as TextElement).text); + break; + case 'video': + this.addUnitStateElementCode(elementModel.id, (elementModel as VideoElement).playbackTime); + break; + case 'audio': + this.addUnitStateElementCode(elementModel.id, (elementModel as AudioElement).playbackTime); + break; + default: + this.addUnitStateElementCode(elementModel.id, (elementModel as InputElement).value); + } + } + private addUnitStateElementCode(id: string, value: InputElementValue): void { if (!this.getUnitStateElement(id)) { const unitStateElementCode: UnitStateElementCode = { id: id, value: value, status: 'NOT_REACHED' }; -- GitLab