import { Location } from "@angular/common"; import { inject, Injectable, OnDestroy } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { MatSnackBar } from "@angular/material/snack-bar"; import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, debounceTime, distinctUntilChanged, Observable, Subscription } from "rxjs"; import { Publication } from "../../core/rest-services/publications/model/publication"; import { PublicationRestService } from "../../core/rest-services/publications/publication.rest-service"; import { copy } from "../../core/utils/ObjectUtils"; import { CodeBlockDialog } from "./code-block-dialog/code-block-dialog.component"; import { PictureSelectionDialog } from "./picture-selection-dialog/picture-selection-dialog.component"; import { PreviewContentRequest } from "../../core/rest-services/publications/model/preview"; import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; declare let Prism: any; export class CursorPosition { start: number; end: number; selectedCharacters: number; constructor(start: number, end: number) { this.start = start; this.end = end; this.selectedCharacters = end - start; } } export interface PublicationEditionState { publication: Publication; cursorPosition: CursorPosition; } const DEFAULT_PUBLICATION: Publication = { id: '', key: '', title: '', text: '', parsedText: '', description: '', creationDate: new Date(), illustrationId: '', categoryId: '', author: { id: '', name: '', image: '' } }; const DEFAULT_CURSOR_POSITION = new CursorPosition(0, 0); const DEFAULT_STATE: PublicationEditionState = { publication: DEFAULT_PUBLICATION, cursorPosition: DEFAULT_CURSOR_POSITION }; @Injectable() export class PublicationEditionService implements OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); private readonly dialog = inject(MatDialog); private readonly formBuilder = inject(FormBuilder); private readonly location = inject(Location); private readonly publicationRestService = inject(PublicationRestService); private readonly snackBar = inject(MatSnackBar); private isLoadingSubject = new BehaviorSubject(false); private stateSubject = new BehaviorSubject(copy(DEFAULT_STATE)); private subscriptions: Subscription[] = []; private isSavingSubject = new BehaviorSubject(false); private isPreviewingSubject = new BehaviorSubject(false); publicationEditionForm: FormGroup = this.formBuilder.group({ title: new FormControl('', [Validators.required]), description: new FormControl('', [Validators.required]), text: new FormControl('', [Validators.required]), illustrationId: new FormControl('', [Validators.required]), categoryId: new FormControl('', [Validators.required]) }); ngOnDestroy(): void { this.subscriptions.forEach(subscription => subscription.unsubscribe()); } private get _state(): PublicationEditionState { return this.stateSubject.value; } private _save(state: PublicationEditionState): void { this.stateSubject.next(state); } private _updateForm(): void { const state = this._state; const publication = state.publication; this.publicationEditionForm.controls['title'].setValue(publication.title); this.publicationEditionForm.controls['description'].setValue(publication.description); this.publicationEditionForm.controls['text'].setValue(publication.text); this.publicationEditionForm.controls['illustrationId'].setValue(publication.illustrationId); this.publicationEditionForm.controls['categoryId'].setValue(publication.categoryId); } get isLoading$(): Observable { return this.isLoadingSubject.asObservable(); } get isSaving$(): Observable { return this.isSavingSubject.asObservable(); } get isPreviewing$(): Observable { return this.isPreviewingSubject.asObservable(); } get state$(): Observable { return this.stateSubject.asObservable(); } get editedPublication(): Publication { return this._state.publication; } loadPublication(): void { this.isLoadingSubject.next(true); this.activatedRoute.paramMap.subscribe(params => { const publicationId = params.get('publicationId'); if (publicationId == undefined) { this.snackBar.open($localize`A technical error occurred while loading publication data.`, $localize`Close`, { duration: 5000 }); this.location.back(); } else { this.publicationRestService.getById(publicationId) .then(publication => { const state = this._state; state.publication = publication; this.stateSubject.next(state); }) .catch(error => { const errorMessage = $localize`A technical error occurred while loading publication data.`; this.snackBar.open(errorMessage, $localize`Close`, {duration: 5000}); console.error(errorMessage, error) }) .finally(() => this.isLoadingSubject.next(false)); } }); } init(publication: Publication): void { const state = this._state; state.publication = publication; this.stateSubject.next(state); this._updateForm(); const formValueChangesSubscription = this.publicationEditionForm.valueChanges .pipe( debounceTime(200), distinctUntilChanged() ) .subscribe(formValue => { const state = this._state; const publication = state.publication; publication.title = formValue.title; publication.description = formValue.description; publication.categoryId = formValue.categoryId; publication.text = formValue.text; this._save(state); }) this.subscriptions.push(formValueChangesSubscription); } private editIllustrationId(pictureId: string): void { const state = this._state; state.publication.illustrationId = pictureId this._save(state); } displayPictureSectionDialog(): void { const dialogRef = this.dialog.open(PictureSelectionDialog); const afterDialogCloseSubscription = dialogRef.afterClosed() .subscribe(newPictureId => { if (newPictureId) { this.editIllustrationId(newPictureId); } }); this.subscriptions.push(afterDialogCloseSubscription); } displayCodeBlockDialog(): void { const dialogRef = this.dialog.open(CodeBlockDialog, { width: '60em' }); const afterDialogCloseSubscription = dialogRef.afterClosed() .subscribe(codeBlockWithLanguage => { if (codeBlockWithLanguage) { this.insertCodeBlock(codeBlockWithLanguage.programmingLanguage, codeBlockWithLanguage.codeBlock); } }); this.subscriptions.push(afterDialogCloseSubscription); } editCursorPosition(positionStart: number, positionEnd: number): void { const state = this._state; state.cursorPosition.start = positionStart; state.cursorPosition.end = positionEnd; this._save(state); } insertTitle(titleNumber: number): void { if (titleNumber >= 1 && titleNumber <= 3) { const state = this._state; const publication = state.publication; const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start); const publicationTextMiddlePart = publication.text.substring(state.cursorPosition.start, state.cursorPosition.end); const publicationTextRightPart = publication.text.substring(state.cursorPosition.end); const textWithTags = `${publicationTextLeftPart}[h${titleNumber}]${publicationTextMiddlePart}[/h${titleNumber}]${publicationTextRightPart}`; publication.text = textWithTags; this._save(state); this._updateForm(); } else { console.error(`Bad value for parameter of function 'insertTitle': '${titleNumber}'.`); } } selectAPicture(): void { const dialogRef = this.dialog.open(PictureSelectionDialog); const afterDialogCloseSubscription = dialogRef.afterClosed() .subscribe(newPictureId => { if (newPictureId) { this.insertPicture(newPictureId); } }); this.subscriptions.push(afterDialogCloseSubscription); } insertPicture(pictureId: string): void { const state = this._state; const publication = state.publication; const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start); const publicationTextRightPart = publication.text.substring(state.cursorPosition.start); const textWithTags = `${publicationTextLeftPart}[img src="/api/pictures/${pictureId}" /]${publicationTextRightPart}`; publication.text = textWithTags; this._save(state); this._updateForm(); } insertLink(): void { const state = this._state; const publication = state.publication; const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start); const publicationTextMiddlePart = publication.text.substring(state.cursorPosition.start, state.cursorPosition.end); const publicationTextRightPart = publication.text.substring(state.cursorPosition.end); const textWithTags = `${publicationTextLeftPart}[link href="" txt="${publicationTextMiddlePart}" /]${publicationTextRightPart}`; publication.text = textWithTags; this._save(state); this._updateForm(); } private insertCodeBlock(programmingLanguage: string, codeBlock: string): void { const state = this._state; const publication = state.publication; const publicationTextLeftPart = publication.text.substring(0, state.cursorPosition.start); const publicationTextRightPart = publication.text.substring(state.cursorPosition.start); const codeBlockInstruction = `\n[code lg="${programmingLanguage}"]\n${codeBlock}\n[/code]\n\n`; const textWithTags = `${publicationTextLeftPart}${codeBlockInstruction}${publicationTextRightPart}`; publication.text = textWithTags; this._save(state); this._updateForm(); } loadPreview(): void { const state = this._state; this.isPreviewingSubject.next(true); const request: PreviewContentRequest = { text: state.publication.text }; this.publicationRestService.preview(request) .then(response => { state.publication.parsedText = response.text; this._save(state); setTimeout(() => Prism.highlightAll(), 1000); }) .catch(error => { console.error(error); }) .finally(() => { this.isPreviewingSubject.next(false); }); } }