diff --git a/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts b/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts index f7fa11ced1b81987b8577bbba4d4b75549cca250..ae82095d8dc651122c10971f5caa690691a23002 100644 --- a/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts +++ b/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts @@ -9,11 +9,6 @@ import { SpacyDialogComponent } from '../spacy-dialog/spacy-dialog.component'; import { LanguagetoolService, Language } from '../../../../services/http/languagetool.service'; import { CreateCommentKeywords } from '../../../../utils/create-comment-keywords'; import { WriteCommentComponent } from '../../write-comment/write-comment.component'; -import { DeepLDialogComponent } from '../deep-ldialog/deep-ldialog.component'; -import { DeepLService } from '../../../../services/http/deep-l.service'; -import { Observable } from 'rxjs'; -import { ViewCommentDataComponent } from '../../view-comment-data/view-comment-data.component'; -import { map } from 'rxjs/operators'; @Component({ selector: 'app-submit-comment', @@ -26,8 +21,6 @@ export class CreateCommentComponent implements OnInit { @Input() user: User; @Input() roomId: string; @Input() tags: string[]; - maxTextCharacters = 500; - maxDataCharacters = 1500; isSendingToSpacy = false; isModerator = false; @@ -38,31 +31,12 @@ export class CreateCommentComponent implements OnInit { public dialog: MatDialog, public languagetoolService: LanguagetoolService, public eventService: EventService, - private deeplService: DeepLService, @Inject(MAT_DIALOG_DATA) public data: any) { } - private static encodeHTML(str: string): string { - return str.replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - private static decodeHTML(str: string): string { - return str.replace(/'/g, '\'') - .replace(/"/g, '"') - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/&/g, '&'); - } - ngOnInit() { this.translateService.use(localStorage.getItem('currentLang')); this.isModerator = this.user && this.user.role > 0; - this.maxTextCharacters = this.isModerator ? 1000 : 500; - this.maxDataCharacters = this.isModerator ? this.maxTextCharacters * 5 : this.maxTextCharacters * 3; } onNoClick(): void { @@ -77,41 +51,7 @@ export class CreateCommentComponent implements OnInit { comment.createdFromLecturer = this.user.role > 0; comment.tag = tag; this.isSendingToSpacy = true; - this.openDeeplDialog(body, text, (newBody: string, newText: string) => { - comment.body = newBody; - this.openSpacyDialog(comment, newText); - }); - } - - openDeeplDialog(body: string, text: string, onClose: (data: string, text: string) => void) { - this.generateDeeplDelta(body).subscribe(([improvedBody, improvedText]) => { - if (improvedText.replace(/\s+/g, '') === text.replace(/\s+/g, '')) { - onClose(body, text); - return; - } - this.dialog.open(DeepLDialogComponent, { - width: '900px', - maxWidth: '100%', - data: { - body, - text, - improvedBody, - improvedText, - maxTextCharacters: this.maxTextCharacters, - maxDataCharacters: this.maxDataCharacters, - isModerator: this.isModerator - } - }).afterClosed().subscribe((res) => { - if (res) { - onClose(res.body, res.text); - } else { - this.isSendingToSpacy = false; - } - }); - }, (_) => { - this.isSendingToSpacy = false; - onClose(body, text); - }); + this.openSpacyDialog(comment, text); } openSpacyDialog(comment: Comment, rawText: string): void { @@ -150,26 +90,4 @@ export class CreateCommentComponent implements OnInit { this.isSendingToSpacy = false; }); } - - private generateDeeplDelta(body: string): Observable<[string, string]> { - const delta = ViewCommentDataComponent.getDeltaFromData(body); - const xml = delta.ops.reduce((acc, e, i) => { - if (typeof e['insert'] === 'string' && e['insert'].trim().length) { - acc += '<x i="' + i + '">' + CreateCommentComponent.encodeHTML(e['insert']) + '</x>'; - e['insert'] = ''; - } - return acc; - }, ''); - return this.deeplService.improveTextStyle(xml).pipe( - map(str => { - const regex = /<x i="(\d+)">([^<]+)<\/x>/gm; - let m; - while ((m = regex.exec(str)) !== null) { - delta.ops[+m[1]]['insert'] += CreateCommentComponent.decodeHTML(m[2]); - } - const text = delta.ops.reduce((acc, el) => acc + (typeof el['insert'] === 'string' ? el['insert'] : ''), ''); - return [ViewCommentDataComponent.getDataFromDelta(delta), text]; - }) - ); - } } diff --git a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts index 5b23145546b8775069fa89f5f4dca9f8ae52e44b..e04ccd81f274c38c49caf1a38ac2af383ee7e60f 100644 --- a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts +++ b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, Inject, OnInit, ViewChild } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { ViewCommentDataComponent } from '../../view-comment-data/view-comment-data.component'; import { NotificationService } from '../../../../services/util/notification.service'; @@ -10,6 +10,7 @@ import { ExplanationDialogComponent } from '../explanation-dialog/explanation-di interface ResultValue { body: string; text: string; + view: ViewCommentDataComponent; } @Component({ @@ -17,7 +18,7 @@ interface ResultValue { templateUrl: './deep-ldialog.component.html', styleUrls: ['./deep-ldialog.component.scss'] }) -export class DeepLDialogComponent implements OnInit { +export class DeepLDialogComponent implements OnInit, AfterViewInit { @ViewChild('normal') normal: ViewCommentDataComponent; @ViewChild('improved') improved: ViewCommentDataComponent; @@ -41,15 +42,23 @@ export class DeepLDialogComponent implements OnInit { this.translateService.use(localStorage.getItem('currentLang')); this.normalValue = { body: this.data.body, - text: this.data.text + text: this.data.text, + view: this.normal }; this.improvedValue = { body: this.data.improvedBody, - text: this.data.improvedText + text: this.data.improvedText, + view: this.improved }; this.radioButtonValue = this.normalValue; } + ngAfterViewInit() { + this.normal.afterEditorInit = () => { + this.normal.buildMarks(this.data.text, this.data.result); + }; + } + buildCloseDialogActionCallback(): () => void { return () => this.dialogRef.close(); } @@ -60,15 +69,18 @@ export class DeepLDialogComponent implements OnInit { if (this.radioButtonValue === this.normalValue) { this.normalValue.body = this.normal.currentData; this.normalValue.text = this.normal.currentText; + this.normalValue.view = this.normal; current = this.normalValue; } else { this.improvedValue.body = this.improved.currentData; this.improvedValue.text = this.improved.currentText; + this.improvedValue.view = this.improved; current = this.improvedValue; } if (WriteCommentComponent.checkInputData(current.body, current.text, this.translateService, this.notificationService, this.data.maxTextCharacters, this.data.maxDataCharacters)) { - this.dialogRef.close(this.radioButtonValue); + this.data.onClose(current.body, current.text, current.view); + this.dialogRef.close(); } }; } diff --git a/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.html b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.html new file mode 100644 index 0000000000000000000000000000000000000000..b5c9a25e25c32c1b2a02f48ebb6bee19b8bdc5d0 --- /dev/null +++ b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.html @@ -0,0 +1,18 @@ +<h3>{{'quill.heading' | translate}}</h3> +<mat-dialog-content> + <mat-form-field appearance="fill"> + <mat-label>{{'quill.tooltip-label-' + data.type | translate}}</mat-label> + <input type="text" + matInput + placeholder="{{'quill.tooltip-placeholder-' + data.type | translate}}" + [(ngModel)]="value"> + </mat-form-field> + <app-custom-markdown [data]="getKatex()" *ngIf="data.type === 'formula'"> + </app-custom-markdown> +</mat-dialog-content> +<app-dialog-action-buttons + [buttonsLabelSection]="'quill'" + [confirmButtonLabel]="'tooltip-action-save'" + [confirmButtonClickAction]="buildConfirmAction()" + [cancelButtonClickAction]="buildCancelAction()"> +</app-dialog-action-buttons> diff --git a/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.scss b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..c7acb4bf6e7f6dd478cc87d9fc5a9ee93fd12013 --- /dev/null +++ b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.spec.ts b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7b4da6e65beb3f73a57be501a41eae40946718b --- /dev/null +++ b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.spec.ts @@ -0,0 +1,26 @@ +/*import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuillInputDialogComponent } from './quill-input-dialog.component'; + +describe('QuillInputDialogComponent', () => { + let component: QuillInputDialogComponent; + let fixture: ComponentFixture<QuillInputDialogComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ QuillInputDialogComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QuillInputDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); + */ diff --git a/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.ts b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..24ab8e0bc532565af740e75f84f0711c61e13a42 --- /dev/null +++ b/src/app/components/shared/_dialogs/quill-input-dialog/quill-input-dialog.component.ts @@ -0,0 +1,84 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import Delta from 'quill-delta'; + +interface DialogData { + type: string; + meta: string; + quill: any; + selection: any; + overrideAction?: (value: string) => void; +} + +@Component({ + selector: 'app-quill-input-dialog', + templateUrl: './quill-input-dialog.component.html', + styleUrls: ['./quill-input-dialog.component.scss'] +}) +export class QuillInputDialogComponent implements OnInit { + + value: string; + + constructor(@Inject(MAT_DIALOG_DATA) public data: DialogData, + private dialogRef: MatDialogRef<QuillInputDialogComponent>) { + } + + private static getVideoUrl(url) { + let match = url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtube\.com\/watch.*v=([a-zA-Z0-9_-]+)/) || + url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtu\.be\/([a-zA-Z0-9_-]+)/) || + url.match(/^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/); + if (match && match[2].length === 11) { + return 'https://www.youtube-nocookie.com/embed/' + match[2] + '?showinfo=0'; + } + match = url.match(/^(?:(https?):\/\/)?(?:www\.)?vimeo\.com\/(\d+)/); + if (match) { + return (match[1] || 'https') + '://player.vimeo.com/video/' + match[2] + '/'; + } + return null; + } + + ngOnInit(): void { + this.value = this.data.meta; + } + + getKatex(): string { + return '$' + this.value + '$'; + } + + buildConfirmAction() { + return () => { + if (this.data.overrideAction) { + this.data.overrideAction(this.value); + this.dialogRef.close(); + return; + } + switch (this.data.type) { + case 'link': + if (this.value) { + const delta = new Delta() + .retain(this.data.selection.index) + .retain(this.data.selection.length, { link: this.value }); + this.data.quill.updateContents(delta); + } + break; + case 'video': + const value = QuillInputDialogComponent.getVideoUrl(this.value) || this.value; + if (value) { + this.data.quill.insertEmbed(this.data.selection.index, 'video', value, 'user'); + } + break; + default: + if (this.value) { + this.data.quill.insertEmbed(this.data.selection.index, this.data.type, this.value, 'user'); + } + break; + } + this.dialogRef.close(); + }; + } + + buildCancelAction() { + return () => this.dialogRef.close(); + } + +} diff --git a/src/app/components/shared/shared.module.ts b/src/app/components/shared/shared.module.ts index 3a35e6bb49f5049330e7aec61733f7069f8e80bf..7637d1bf80426a544fd9c61ea8c8aa2efac520bd 100644 --- a/src/app/components/shared/shared.module.ts +++ b/src/app/components/shared/shared.module.ts @@ -53,6 +53,7 @@ import { QuillModule } from 'ngx-quill'; import { ViewCommentDataComponent } from './view-comment-data/view-comment-data.component'; import { DeepLDialogComponent } from './_dialogs/deep-ldialog/deep-ldialog.component'; import { ExplanationDialogComponent } from './_dialogs/explanation-dialog/explanation-dialog.component'; +import { QuillInputDialogComponent } from './_dialogs/quill-input-dialog/quill-input-dialog.component'; @NgModule({ imports: [ @@ -111,7 +112,8 @@ import { ExplanationDialogComponent } from './_dialogs/explanation-dialog/explan ScrollIntoViewDirective, ViewCommentDataComponent, DeepLDialogComponent, - ExplanationDialogComponent + ExplanationDialogComponent, + QuillInputDialogComponent ], exports: [ RoomJoinComponent, diff --git a/src/app/components/shared/view-comment-data/view-comment-data.component.scss b/src/app/components/shared/view-comment-data/view-comment-data.component.scss index f5d4687c77c7678f6361d27223d69c56e3d4b729..dcf3fc961a68a77048a5f8af2cc74c3d50662ac4 100644 --- a/src/app/components/shared/view-comment-data/view-comment-data.component.scss +++ b/src/app/components/shared/view-comment-data/view-comment-data.component.scss @@ -42,22 +42,6 @@ } .ql-tooltip { - &[data-mode=formula]::before { - --quill-tooltip-label: var(--quill-tooltip-label-formula); - } - - &[data-mode=video]::before { - --quill-tooltip-label: var(--quill-tooltip-label-video); - } - - &[data-mode=image]::before { - --quill-tooltip-label: var(--quill-tooltip-label-image); - } - - &[data-mode=link]::before { - --quill-tooltip-label: var(--quill-tooltip-label-link); - } - &::before { content: var(--quill-tooltip-label) !important; } diff --git a/src/app/components/shared/view-comment-data/view-comment-data.component.ts b/src/app/components/shared/view-comment-data/view-comment-data.component.ts index c0aa27c30fd0c6d698aaee9e86d507c8a391af8e..1829bbc58530c787c34f63f1cfc999a5ce3aaff2 100644 --- a/src/app/components/shared/view-comment-data/view-comment-data.component.ts +++ b/src/app/components/shared/view-comment-data/view-comment-data.component.ts @@ -7,6 +7,10 @@ import 'quill-emoji/dist/quill-emoji.js'; import { LanguageService } from '../../../services/util/language.service'; import { TranslateService } from '@ngx-translate/core'; import { DeviceInfoService } from '../../../services/util/device-info.service'; +import { MatDialog } from '@angular/material/dialog'; +import { QuillInputDialogComponent } from '../_dialogs/quill-input-dialog/quill-input-dialog.component'; +import { Marks } from './view-comment-data.marks'; +import { LanguagetoolResult } from '../../../services/http/languagetool.service'; Quill.register('modules/imageResize', ImageResize); @@ -48,29 +52,26 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { @Input() maxTextCharacters = 500; @Input() maxDataCharacters = 1500; @Input() placeHolderText = ''; - @Input() markEvents?: { - onCreate: (markContainer: HTMLDivElement, tooltipContainer: HTMLDivElement, editor: QuillEditorComponent) => void; - onChange: (delta: any) => void; - onEditorChange: () => void; - onDocumentClick: (e) => void; - }; + @Input() afterEditorInit?: () => void; currentText = '\n'; quillModules: QuillModules = { toolbar: { container: participantToolbar, handlers: { image: () => this.handle('image'), - video: () => this.handleVideo(), - link: () => this.handleLink(), + video: () => this.handle('video'), + link: () => this.handle('link'), formula: () => this.handle('formula') } } }; private _currentData = null; + private _marks: Marks; constructor(private languageService: LanguageService, private translateService: TranslateService, - private deviceInfo: DeviceInfoService) { + private deviceInfo: DeviceInfoService, + private dialog: MatDialog) { this.languageService.langEmitter.subscribe(lang => { this.translateService.use(lang); if (this.isEditor) { @@ -137,9 +138,7 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { ngAfterViewInit() { if (this.isEditor) { this.editor.onContentChanged.subscribe(e => { - if (this.markEvents && this.markEvents.onChange) { - this.markEvents.onChange(e.delta); - } + this._marks.onDataChange(e.delta); this._currentData = ViewCommentDataComponent.getDataFromDelta(e.content); this.currentText = e.text; // remove background @@ -157,20 +156,22 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { } }); this.editor.onEditorCreated.subscribe(_ => { - if (this.markEvents && this.markEvents.onCreate) { - this.markEvents.onCreate(this.editorErrorLayer.nativeElement, this.tooltipContainer.nativeElement, this.editor); - } + this._marks = new Marks(this.editorErrorLayer.nativeElement, this.tooltipContainer.nativeElement, this.editor); if (this._currentData) { this.set(this._currentData); } (this.editor.editorElem.firstElementChild as HTMLElement).focus(); + this.overrideQuillTooltip(); this.syncErrorLayer(); - setTimeout(() => this.syncErrorLayer(), 200); // animations? + setTimeout(() => { + this.syncErrorLayer(); + if (this.afterEditorInit) { + this.afterEditorInit(); + } + }, 200); // animations? }); this.editor.onEditorChanged.subscribe(_ => { - if (this.markEvents && this.markEvents.onEditorChange) { - this.markEvents.onEditorChange(); - } + this._marks.sync(); this.syncErrorLayer(); const elem: HTMLDivElement = document.querySelector('div.ql-tooltip'); if (elem) { @@ -196,9 +197,11 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { } onDocumentClick(e) { - if (this.markEvents && this.markEvents.onDocumentClick) { - this.markEvents.onDocumentClick(e); + if (!this._marks) { + return; } + const range = this.editor.quillEditor.getSelection(false); + this._marks.onClick(range && range.length === 0 ? range.index : null); } clear(): void { @@ -228,112 +231,87 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { }); } - private syncErrorLayer(): void { - const pos = this.editor.elementRef.nativeElement.getBoundingClientRect(); - const elem = this.editorErrorLayer.nativeElement; - elem.style.width = pos.width + 'px'; - elem.style.height = pos.height + 'px'; - elem.style.marginBottom = '-' + elem.style.height; + buildMarks(text: string, result: LanguagetoolResult) { + this._marks.buildErrors(text, result); } - private handleLink(): void { - const quill = this.editor.quillEditor; - const selection = quill.getSelection(false); - if (!selection || !selection.length) { + copyMarks(viewCommentData: ViewCommentDataComponent) { + if (viewCommentData === this) { return; } - const tooltip = quill.theme.tooltip; - const originalSave = tooltip.save; - const originalHide = tooltip.hide; - tooltip.save = () => { - const value = tooltip.textbox.value; - if (value) { - const delta = new Delta() - .retain(selection.index) - .retain(selection.length, { link: value }); - quill.updateContents(delta); - tooltip.hide(); - } - }; - // Called on hide and save. - tooltip.hide = () => { - tooltip.save = originalSave; - tooltip.hide = originalHide; - tooltip.hide(); - }; - tooltip.edit('link'); - tooltip.textbox.value = quill.getText(selection.index, selection.length); - this.translateService.get('quill.tooltip-placeholder-link') - .subscribe(translation => tooltip.textbox.placeholder = translation); + this._marks.copy(viewCommentData._marks); } - private handleVideo(): void { - const quill = this.editor.quillEditor; - const tooltip = quill.theme.tooltip; - const originalSave = tooltip.save; - const originalHide = tooltip.hide; - tooltip.save = () => { - const range = quill.getSelection(true); - const value = this.getVideoUrl(tooltip.textbox.value); - if (value) { - quill.insertEmbed(range.index, 'video', value, 'user'); + private overrideQuillTooltip() { + const tooltip = this.editor.quillEditor.theme.tooltip; + const prev = tooltip.show; + let range; + tooltip.show = () => { + const sel = this.editor.quillEditor.getSelection(false); + const delta = this.editor.quillEditor.getContents(); + let currentSize = 0; + for (const op of delta.ops) { + if (typeof op['insert'] === 'string') { + const start = currentSize; + const len = op['insert'].length; + currentSize += len; + if (sel.index < currentSize) { + range = { index: start, length: len }; + break; + } + } else { + currentSize += 1; + } } + prev.bind(tooltip)(); }; - // Called on hide and save. - tooltip.hide = () => { - tooltip.save = originalSave; - tooltip.hide = originalHide; - tooltip.hide(); + tooltip.edit = (type: string, value: string) => { + this.handle(type, value, (val: string) => { + const delta = new Delta() + .retain(range.index) + .retain(range.length, { link: val }); + this.editor.quillEditor.updateContents(delta); + }); }; - tooltip.edit('video'); - this.translateService.get('quill.tooltip-placeholder-video') - .subscribe(translation => tooltip.textbox.placeholder = translation); } - private getVideoUrl(url) { - let match = url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtube\.com\/watch.*v=([a-zA-Z0-9_-]+)/) || - url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtu\.be\/([a-zA-Z0-9_-]+)/) || - url.match(/^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/); - if (match && match[2].length === 11) { - return 'https://www.youtube-nocookie.com/embed/' + match[2] + '?showinfo=0'; - } - match = url.match(/^(?:(https?):\/\/)?(?:www\.)?vimeo\.com\/(\d+)/); - if (match) { - return (match[1] || 'https') + '://player.vimeo.com/video/' + match[2] + '/'; + private handle(type: string, overrideMeta = '', overrideAction = null) { + const quill = this.editor.quillEditor; + let meta: any = null; + const selection = quill.getSelection(false); + if (overrideMeta) { + meta = overrideMeta; + } else if (type === 'link') { + if (!selection || !selection.length) { + return; + } + meta = quill.getText(selection.index, selection.length); } - return null; + this.dialog.open(QuillInputDialogComponent, { + width: '900px', + maxWidth: '100%', + maxHeight: 'calc( 100vh - 20px )', + autoFocus: false, + data: { + type, + selection, + quill, + meta, + overrideAction + } + }); } - - private handle(type: string): void { - const quill = this.editor.quillEditor; - const tooltip = quill.theme.tooltip; - const originalSave = tooltip.save; - const originalHide = tooltip.hide; - tooltip.save = () => { - const range = quill.getSelection(true); - const value = tooltip.textbox.value; - if (value) { - quill.insertEmbed(range.index, type, value, 'user'); - } - }; - // Called on hide and save. - tooltip.hide = () => { - tooltip.save = originalSave; - tooltip.hide = originalHide; - tooltip.hide(); - }; - tooltip.edit(type); - this.translateService.get('quill.tooltip-placeholder-' + type) - .subscribe(translation => tooltip.textbox.placeholder = translation); + private syncErrorLayer(): void { + const pos = this.editor.elementRef.nativeElement.getBoundingClientRect(); + const elem = this.editorErrorLayer.nativeElement; + elem.style.width = pos.width + 'px'; + elem.style.height = pos.height + 'px'; + elem.style.marginBottom = '-' + elem.style.height; } private updateCSSVariables() { - const variables = [ - 'quill.tooltip-remove', 'quill.tooltip-action-save', 'quill.tooltip-action', 'quill.tooltip-label', - 'quill.tooltip-label-link', 'quill.tooltip-label-image', 'quill.tooltip-label-video', - 'quill.tooltip-label-formula' - ]; + const variables = ['quill.tooltip-remove', 'quill.tooltip-action', 'quill.tooltip-label']; for (const variable of variables) { this.translateService.get(variable).subscribe(translation => { document.body.style.setProperty('--' + variable.replace('.', '-'), JSON.stringify(translation)); diff --git a/src/app/components/shared/write-comment/write-comment.marks.ts b/src/app/components/shared/view-comment-data/view-comment-data.marks.ts similarity index 84% rename from src/app/components/shared/write-comment/write-comment.marks.ts rename to src/app/components/shared/view-comment-data/view-comment-data.marks.ts index d9cef879a1a96ff689181aa49e5eecd11a8f1183..b2cca2120940d96c68b0ae8d9ce3626925e52008 100644 --- a/src/app/components/shared/write-comment/write-comment.marks.ts +++ b/src/app/components/shared/view-comment-data/view-comment-data.marks.ts @@ -1,5 +1,6 @@ import { LanguagetoolResult } from '../../../services/http/languagetool.service'; import { QuillEditorComponent } from 'ngx-quill'; +import { mark } from '@angular/compiler-cli/src/ngtsc/perf/src/clock'; class ContentIndexFinder { @@ -115,19 +116,12 @@ export class Marks { } buildErrors(initialText: string, res: LanguagetoolResult): void { + this.onClick(null); + this.clear(); const indexFinder = new ContentIndexFinder(this.editor.quillEditor.getContents().ops); - for (let i = 0; i < res.matches.length; i++) { - const match = res.matches[i]; + for (const match of res.matches) { const [start, len] = indexFinder.adjustTextIndexes(match.offset, match.length); - const mark = new Mark(start, len, this.markContainer, this.tooltipContainer, this.editor.quillEditor); - mark.setSuggestions(res, i, () => { - const index = this.textErrors.findIndex(elem => elem === mark); - if (index >= 0) { - this.textErrors.splice(index, 1); - } - mark.remove(); - }); - this.textErrors.push(mark); + this.createMark(start, len, match); } this.sync(); } @@ -139,10 +133,33 @@ export class Marks { error.syncMark(parentRect, editorRect.y - parentRect.y); } } + + copy(marks: Marks) { + this.onClick(null); + this.clear(); + for (const oldMark of marks.textErrors) { + this.createMark(oldMark.startIndex, oldMark.markLength, oldMark); + } + this.sync(); + } + + private createMark(start: number, len: number, dataObject: any) { + const newMark = new Mark(start, len, this.markContainer, this.tooltipContainer, this.editor.quillEditor); + newMark.setSuggestions(dataObject.replacements, dataObject.message, () => { + const index = this.textErrors.findIndex(elem => elem === newMark); + if (index >= 0) { + this.textErrors.splice(index, 1); + } + newMark.remove(); + }); + this.textErrors.push(newMark); + } } class Mark { + public replacements: { value?: string }[]; + public message: string; private marks: HTMLSpanElement[] = []; private dropdown: HTMLDivElement; @@ -165,12 +182,12 @@ class Mark { } this.marks.length = boundaries.length; for (let i = 0; i < this.marks.length; i++) { - const mark = this.marks[i]; + const current = this.marks[i]; const rect = this.quillEditor.getBounds(boundaries[i][0], boundaries[i][1]); - mark.style.setProperty('--width', rect.width + 'px'); - mark.style.setProperty('--height', rect.height + 'px'); - mark.style.setProperty('--left', rect.left + 'px'); - mark.style.setProperty('--top', (rect.top + offset) + 'px'); + current.style.setProperty('--width', rect.width + 'px'); + current.style.setProperty('--height', rect.height + 'px'); + current.style.setProperty('--left', rect.left + 'px'); + current.style.setProperty('--top', (rect.top + offset) + 'px'); } } @@ -200,21 +217,23 @@ class Mark { } remove() { - for (const mark of this.marks) { - mark.remove(); + for (const current of this.marks) { + current.remove(); } this.marks.length = 0; this.dropdown.remove(); } - setSuggestions(result: LanguagetoolResult, index: number, removeMark: () => void): void { + setSuggestions(replacements: { value?: string }[], message: string, removeMark: () => void): void { + this.replacements = replacements; + this.message = message; this.dropdown = document.createElement('div'); this.dropdown.classList.add('dropdownBlock'); - const suggestions = result.matches[index].replacements; + const suggestions = replacements; if (!suggestions.length) { const dropdownElem = document.createElement('span'); dropdownElem.classList.add('error-message'); - dropdownElem.append(result.matches[index].message); + dropdownElem.append(message); this.dropdown.append(dropdownElem); } else { const length = suggestions.length > 3 ? 3 : suggestions.length; diff --git a/src/app/components/shared/write-comment/write-comment.component.html b/src/app/components/shared/write-comment/write-comment.component.html index 32a9d67ce13b57558f363173015f13b35dd7050e..6caa9719121e6d2fad59369cb4236b9549ea7c8e 100644 --- a/src/app/components/shared/write-comment/write-comment.component.html +++ b/src/app/components/shared/write-comment/write-comment.component.html @@ -50,8 +50,7 @@ [isEditor]="true" [maxTextCharacters]="maxTextCharacters" [maxDataCharacters]="maxDataCharacters" - [placeHolderText]="placeholder" - [markEvents]="getMarkEvents()"></app-view-comment-data> + [placeHolderText]="placeholder"></app-view-comment-data> <ars-row ars-flex-box *ngIf="enabled" class="spellcheck"> <ars-col> <button diff --git a/src/app/components/shared/write-comment/write-comment.component.ts b/src/app/components/shared/write-comment/write-comment.component.ts index 2ddeee687f1269afac44cd494f735cd239302a6c..0343ea6f113ce616bfd2f94bcde384615dee5e58 100644 --- a/src/app/components/shared/write-comment/write-comment.component.ts +++ b/src/app/components/shared/write-comment/write-comment.component.ts @@ -1,14 +1,16 @@ import { Component, ElementRef, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { Language, LanguagetoolService } from '../../../services/http/languagetool.service'; +import { Language, LanguagetoolResult, LanguagetoolService } from '../../../services/http/languagetool.service'; import { Comment } from '../../../models/comment'; import { NotificationService } from '../../../services/util/notification.service'; import { EventService } from '../../../services/util/event.service'; -import { Marks } from './write-comment.marks'; import { LanguageService } from '../../../services/util/language.service'; -import { QuillEditorComponent } from 'ngx-quill'; import { ViewCommentDataComponent } from '../view-comment-data/view-comment-data.component'; -import { DeepLService } from '../../../services/http/deep-l.service'; +import { DeepLService, SourceLang, TargetLang } from '../../../services/http/deep-l.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { DeepLDialogComponent } from '../_dialogs/deep-ldialog/deep-ldialog.component'; +import { MatDialog } from '@angular/material/dialog'; @Component({ selector: 'app-write-comment', @@ -42,14 +44,14 @@ export class WriteCommentComponent implements OnInit { isSpellchecking = false; hasSpellcheckConfidence = true; newLang = 'auto'; - // Marks - marks: Marks; constructor(private notification: NotificationService, private languageService: LanguageService, private translateService: TranslateService, public eventService: EventService, public languagetoolService: LanguagetoolService, + private deeplService: DeepLService, + private dialog: MatDialog, public deepl: DeepLService) { this.languageService.langEmitter.subscribe(lang => { this.translateService.use(lang); @@ -82,6 +84,22 @@ export class WriteCommentComponent implements OnInit { return true; } + private static encodeHTML(str: string): string { + return str.replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private static decodeHTML(str: string): string { + return str.replace(/'/g, '\'') + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); + } + ngOnInit(): void { this.translateService.use(localStorage.getItem('currentLang')); if (this.isCommentAnswer) { @@ -111,22 +129,11 @@ export class WriteCommentComponent implements OnInit { }; } - onDocumentClick(e) { - if (!this.marks) { - return; - } - const range = this.commentData.editor.quillEditor.getSelection(false); - this.marks.onClick(range && range.length === 0 ? range.index : null); - } - checkGrammar() { this.grammarCheck(this.commentData.currentText, this.langSelect && this.langSelect.nativeElement); } grammarCheck(rawText: string, langSelect: HTMLSpanElement): void { - this.onDocumentClick({ - target: document - }); this.isSpellchecking = true; this.hasSpellcheckConfidence = true; this.checkSpellings(rawText).subscribe((wordsCheck) => { @@ -148,10 +155,17 @@ export class WriteCommentComponent implements OnInit { } langSelect.innerHTML = this.newLang; } - this.marks.clear(); - this.marks.buildErrors(rawText, wordsCheck); - }, () => { - this.isSpellchecking = false; + const previous = this.commentData.currentData; + this.openDeeplDialog(previous, rawText, wordsCheck, + (data: string, text: string, view: ViewCommentDataComponent) => { + if (view === this.commentData) { + this.commentData.buildMarks(rawText, wordsCheck); + } else { + this.commentData.currentData = data; + this.commentData.copyMarks(view); + } + this.isSpellchecking = false; + }); }, () => { this.isSpellchecking = false; }); @@ -165,19 +179,59 @@ export class WriteCommentComponent implements OnInit { return this.languagetoolService.checkSpellings(text, language); } - getMarkEvents() { - return { - onCreate: (markContainer: HTMLDivElement, tooltipContainer: HTMLDivElement, editor: QuillEditorComponent) => { - this.marks = new Marks(markContainer, tooltipContainer, editor); - }, - onChange: (delta: any) => { - this.marks.onDataChange(delta); - }, - onEditorChange: () => { - this.marks.sync(); - }, - onDocumentClick: (e) => this.onDocumentClick(e) - }; + private openDeeplDialog(body: string, + text: string, + result: LanguagetoolResult, + onClose: (data: string, text: string, view: ViewCommentDataComponent) => void) { + let target = TargetLang.EN_US; + if (result.language.detectedLanguage.code.toUpperCase().startsWith(SourceLang.EN)) { + target = TargetLang.DE; + } + this.generateDeeplDelta(body, target).subscribe(([improvedBody, improvedText]) => { + if (improvedText.replace(/\s+/g, '') === text.replace(/\s+/g, '')) { + onClose(body, text, this.commentData); + return; + } + this.dialog.open(DeepLDialogComponent, { + width: '900px', + maxWidth: '100%', + data: { + body, + text, + improvedBody, + improvedText, + maxTextCharacters: this.maxTextCharacters, + maxDataCharacters: this.maxDataCharacters, + isModerator: this.isModerator, + result, + onClose + } + }); + }, (_) => { + onClose(body, text, this.commentData); + }); + } + + private generateDeeplDelta(body: string, targetLang: TargetLang): Observable<[string, string]> { + const delta = ViewCommentDataComponent.getDeltaFromData(body); + const xml = delta.ops.reduce((acc, e, i) => { + if (typeof e['insert'] === 'string' && e['insert'].trim().length) { + acc += '<x i="' + i + '">' + WriteCommentComponent.encodeHTML(e['insert']) + '</x>'; + e['insert'] = ''; + } + return acc; + }, ''); + return this.deeplService.improveTextStyle(xml, targetLang).pipe( + map(str => { + const regex = /<x i="(\d+)">([^<]+)<\/x>/gm; + let m; + while ((m = regex.exec(str)) !== null) { + delta.ops[+m[1]]['insert'] += WriteCommentComponent.decodeHTML(m[2]); + } + const text = delta.ops.reduce((acc, el) => acc + (typeof el['insert'] === 'string' ? el['insert'] : ''), ''); + return [ViewCommentDataComponent.getDataFromDelta(delta), text]; + }) + ); } } diff --git a/src/app/services/http/deep-l.service.ts b/src/app/services/http/deep-l.service.ts index f55238f5250b4cf707930883a0f8ff856a34e332..1462c9f5349895432b7b4b14180b5f0fd814f2e1 100644 --- a/src/app/services/http/deep-l.service.ts +++ b/src/app/services/http/deep-l.service.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { catchError, map, tap, timeout } from 'rxjs/operators'; import { flatMap } from 'rxjs/internal/operators'; +import { LanguagetoolService } from './languagetool.service'; const httpOptions = { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -18,11 +19,61 @@ interface DeepLResult { }[]; } -type SourceLang = 'BG' | 'CS' | 'DA' | 'DE' | 'EL' | 'EN' | 'ES' | 'ET' | 'FI' | 'FR' | 'HU' | 'IT' | 'JA' | 'LT' - | 'LV' | 'NL' | 'PL' | 'PT' | 'RO' | 'RU' | 'SK' | 'SL' | 'SV' | 'ZH'; +export enum SourceLang { + BG = 'BG', + CS = 'CS', + DA = 'DA', + DE = 'DE', + EL = 'EL', + EN = 'EN', + ES = 'ES', + ET = 'ET', + FI = 'FI', + FR = 'FR', + HU = 'HU', + IT = 'IT', + JA = 'JA', + LT = 'LT', + LV = 'LV', + NL = 'NL', + PL = 'PL', + PT = 'PT', + RO = 'RO', + RU = 'RU', + SK = 'SK', + SL = 'SL', + SV = 'SV', + ZH = 'ZH' +} -type TargetLang = 'BG' | 'CS' | 'DA' | 'DE' | 'EL' | 'EN-GB' | 'EN-US' | 'ES' | 'ET' | 'FI' | 'FR' | 'HU' | 'IT' | - 'JA' | 'LT' | 'LV' | 'NL' | 'PL' | 'PT-PT' | 'PT-BR' | 'RO' | 'RU' | 'SK' | 'SL' | 'SV' | 'ZH'; +export enum TargetLang { + BG = 'BG', + CS = 'CS', + DA = 'DA', + DE = 'DE', + EL = 'EL', + EN_GB = 'EN-GB', + EN_US = 'EN-US', + ES = 'ES', + ET = 'ET', + FI = 'FI', + FR = 'FR', + HU = 'HU', + IT = 'IT', + JA = 'JA', + LT = 'LT', + LV = 'LV', + NL = 'NL', + PL = 'PL', + PT_PT = 'PT-PT', + PT_BR = 'PT-BR', + RO = 'RO', + RU = 'RU', + SK = 'SK', + SL = 'SL', + SV = 'SV', + ZH = 'ZH' +} @Injectable({ providedIn: 'root' @@ -33,36 +84,36 @@ export class DeepLService extends BaseHttpService { super(); } - private static transformSourceToTarget(lang: SourceLang): TargetLang { + public static transformSourceToTarget(lang: SourceLang): TargetLang { switch (lang) { - case 'EN': - return 'EN-US'; - case 'PT': - return 'PT-PT'; + case SourceLang.EN: + return TargetLang.EN_US; + case SourceLang.PT: + return TargetLang.PT_PT; default: - return lang; + return TargetLang[lang]; } } - private static supportsFormality(lang: TargetLang): boolean { + public static supportsFormality(lang: TargetLang): boolean { switch (lang) { - case 'DE': - case 'ES': - case 'FR': - case 'IT': - case 'NL': - case 'PL': - case 'PT-BR': - case 'PT-PT': - case 'RU': + case TargetLang.DE: + case TargetLang.ES: + case TargetLang.FR: + case TargetLang.IT: + case TargetLang.NL: + case TargetLang.PL: + case TargetLang.PT_BR: + case TargetLang.PT_PT: + case TargetLang.RU: return true; default: return false; } } - improveTextStyle(text: string): Observable<string> { - return this.makeXMLTranslateRequest(text, 'EN-US').pipe( + improveTextStyle(text: string, temTargetLang: TargetLang): Observable<string> { + return this.makeXMLTranslateRequest(text, temTargetLang).pipe( flatMap(result => this.makeXMLTranslateRequest( result.translations[0].text, @@ -74,7 +125,7 @@ export class DeepLService extends BaseHttpService { private makeXMLTranslateRequest(text: string, targetLang: TargetLang): Observable<DeepLResult> { const url = '/deepl/translate'; - const formality = DeepLService.supportsFormality(targetLang) ? '&formality=more' : ''; + const formality = DeepLService.supportsFormality(targetLang) ? '&formality=less' : ''; const additional = '?target_lang=' + encodeURIComponent(targetLang) + '&tag_handling=xml' + formality + '&text=' + encodeURIComponent(text); diff --git a/src/assets/i18n/creator/de.json b/src/assets/i18n/creator/de.json index f5327a8de9da5e7005b673a6bd6c9ea2e6158b70..a36b72b5e4ddc1367e477c5ec9dec09e08f7e319 100644 --- a/src/assets/i18n/creator/de.json +++ b/src/assets/i18n/creator/de.json @@ -191,7 +191,7 @@ "edit-favorite-reset": "Markierung zurücksetzen", "edit-bookmark": "Lesezeichen setzen", "edit-bookmark-reset": "Markierung zurücksetzen", - "grammar-check": "Rechtschreibprüfung", + "grammar-check": "Text prüfen", "show-comment-with-filter": "Vulgäre Wörter ausixen", "show-comment-without-filter": "Vulgäre Wörter anzeigen", "upvote": "positiv", @@ -256,6 +256,8 @@ "no-empty-name": "Gib einen Namen ein. Der Raum-Code wird generiert." }, "quill": { + "cancel": "Abbrechen", + "heading": "Quill Input", "tooltip-remove": "Löschen", "tooltip-action-save": "Speichern", "tooltip-action": "Editieren", diff --git a/src/assets/i18n/creator/en.json b/src/assets/i18n/creator/en.json index 7b4e9c2142664f317342c58ddaae8d6cf3cf99c0..55bffd9a0421d7d6ca879cf6d8fcc81123ccc830 100644 --- a/src/assets/i18n/creator/en.json +++ b/src/assets/i18n/creator/en.json @@ -192,7 +192,7 @@ "edit-favorite-reset": "Remove marker", "edit-bookmark": "Bookmark", "edit-bookmark-reset": "Remove marker", - "grammar-check": "Spell check", + "grammar-check": "Check text", "show-comment-with-filter": "Hide vulgar words", "show-comment-without-filter": "Show vulgar words", "upvote": "upvotes", @@ -257,6 +257,8 @@ "no-empty-name": "Please enter a name." }, "quill": { + "cancel": "Cancel", + "heading": "Quill Input", "tooltip-remove": "Delete", "tooltip-action-save": "Save", "tooltip-action": "Edit", diff --git a/src/assets/i18n/participant/de.json b/src/assets/i18n/participant/de.json index fa82f5943da3952a98d26feb146d5d96cf1065f6..4e2de8bea3d1d7afa84ad5e5e453758b60f13072 100644 --- a/src/assets/i18n/participant/de.json +++ b/src/assets/i18n/participant/de.json @@ -160,7 +160,7 @@ "show-more": "Mehr ansehen", "show-less": "Weniger anzeigen", "sure": "Bist du sicher?", - "grammar-check": "Rechtschreibprüfung", + "grammar-check": "Text prüfen", "upvote": "positiv", "downvote": "negativ" }, @@ -266,6 +266,8 @@ "questions-blocked": "Neue Fragen deaktiviert " }, "quill": { + "cancel": "Abbrechen", + "heading": "Quill Input", "tooltip-remove": "Löschen", "tooltip-action-save": "Speichern", "tooltip-action": "Editieren", diff --git a/src/assets/i18n/participant/en.json b/src/assets/i18n/participant/en.json index acee38a8dcbbdbbe4b396db7418c80e113e98a17..6b0b57d954a33625aa457f7ddd9d5cc28619a6a9 100644 --- a/src/assets/i18n/participant/en.json +++ b/src/assets/i18n/participant/en.json @@ -169,7 +169,7 @@ "show-more": "Show more", "show-less": "Show less", "delete": "Delete question", - "grammar-check": "Spell check", + "grammar-check": "Check text", "upvote": "upvotes", "downvote": "downvotes" }, @@ -272,6 +272,8 @@ "questions-blocked": "New questions blocked" }, "quill": { + "cancel": "Cancel", + "heading": "Quill Input", "tooltip-remove": "Delete", "tooltip-action-save": "Save", "tooltip-action": "Edit",