/* * Squidex Headless CMS * * @license * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ import { AsyncPipe } from '@angular/common'; import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { BehaviorSubject, catchError, of, switchMap } from 'rxjs'; import { HTTP, ModalDirective, TypedSimpleChanges } from '@app/framework'; import { ApiUrlConfig, AppsState, AssetDto, AssetsService, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types } from '@app/shared/internal'; import { AssetDialogComponent } from '../assets/asset-dialog.component'; import { AssetSelectorComponent } from '../assets/asset-selector.component'; import { ChatDialogComponent } from '../chat-dialog.component'; import { ContentSelectorComponent } from '../references/content-selector.component'; export const SQX_RICH_EDITOR_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RichEditorComponent), multi: true, }; @Component({ standalone: true, selector: 'sqx-rich-editor', styleUrls: ['./rich-editor.component.scss'], templateUrl: './rich-editor.component.html', providers: [ SQX_RICH_EDITOR_CONTROL_VALUE_ACCESSOR, ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AssetDialogComponent, AssetSelectorComponent, AsyncPipe, ChatDialogComponent, ContentSelectorComponent, ModalDirective, ], }) export class RichEditorComponent extends StatefulControlComponent<{}, EditorValue> implements AfterViewInit, OnDestroy { private readonly assetId = new BehaviorSubject(null); private editorWrapper?: SquidexEditorWrapper; private value?: string; private currentContents?: ResolvablePromise; private currentAssets?: ResolvablePromise; private currentChat?: ResolvablePromise; @Output() public assetPluginClick = new EventEmitter(); @Output() public annotationsCreate = new EventEmitter(); @Output() public annotationsUpdate = new EventEmitter>(); @Output() public annotationsSelect = new EventEmitter>(); @Input({ required: true }) public hasChatBot = false; @Input() public hasAnnotations = false; @Input() public annotations?: ReadonlyArray | null; @Input() public schemaIds?: ReadonlyArray; @Input() public language!: LanguageDto; @Input() public languages!: ReadonlyArray; @Input() public folderId = ''; @Input({ required: true }) public classNames?: ReadonlyArray; @Input({ required: true }) public mode: SquidexEditorMode = 'Html'; @Input({ transform: booleanAttribute }) public set disabled(value: boolean | undefined | null) { this.setDisabledState(value === true); } @ViewChild('editor', { static: false }) public editor!: ElementRef; public chatDialog = new DialogModel(); public assetsDialog = new DialogModel(); public assetToEdit = this.assetId.pipe( switchMap(id => { if (id) { return this.assetService.getAsset(this.appsState.appName, id); } else { return of(null); } }), catchError(() => of(null))); public contentsDialog = new DialogModel(); constructor( private readonly apiUrl: ApiUrlConfig, private readonly appsState: AppsState, private readonly assetUploader: AssetUploaderState, private readonly assetService: AssetsService, private readonly resourceLoader: ResourceLoaderService, ) { super({}); } public ngOnDestroy() { if (this.editorWrapper) { this.editorWrapper.destroy?.(); this.editorWrapper = undefined; } } public ngOnChanges(changes: TypedSimpleChanges) { if (changes.annotations) { this.editorWrapper?.setAnnotations(this.annotations); } } public async ngAfterViewInit() { await Promise.all([ this.resourceLoader.loadLocalScript('editor/squidex-editor.js'), ]); this.editorWrapper = new SquidexEditorWrapper(this.editor.nativeElement, { onSelectAIText: async () => { if (this.snapshot.isDisabled) { return; } this.currentChat = new ResolvablePromise(); this.chatDialog.show(); return await this.currentChat.promise; }, onSelectAssets: async () => { if (this.snapshot.isDisabled) { return; } this.currentAssets = new ResolvablePromise(); this.assetsDialog.show(); return await this.currentAssets.promise; }, onSelectContents: async () => { if (this.snapshot.isDisabled) { return; } this.currentContents = new ResolvablePromise(); this.contentsDialog.show(); return await this.currentContents.promise; }, onUpload: (requests: UploadRequest[]) => { return this.uploadFiles(requests); }, onChange: (value: EditorValue) => { this.callChange(value); }, onEditAsset: id => { this.assetId.next(id); }, onAnnotationCreate: event => { this.annotationsCreate.emit(event); }, onAnnotationsUpdate: event => { this.annotationsUpdate.emit(event); }, onAnnotationsFocus: event => { this.annotationsSelect.emit(event); }, onEditContent: (schemaName, id) => { const url = this.apiUrl.buildUrl(`/app/${this.appsState.appName}/content/${schemaName}/${id}`); window.open(url, '_blank'); }, mode: this.mode, annotations: this.annotations, appName: this.appsState.appName, baseUrl: this.apiUrl.buildUrl(''), canAddAnnotation: this.hasAnnotations, canSelectAIText: this.hasChatBot, canSelectAssets: true, canSelectContents: !!this.schemaIds, classNames: this.classNames, isDisabled: this.snapshot.isDisabled, value: this.value || '', }); } public reset() { this.ngOnDestroy(); setTimeout(() => { this.ngAfterViewInit(); }); } public writeValue(obj: any) { if (this.editorWrapper) { this.editorWrapper?.setValue(obj); } else { this.value = obj; } } public onDisabled() { if (this.editorWrapper) { this.editorWrapper?.setIsDisabled(this.snapshot.isDisabled); } } public insertText(content: string | HTTP.UploadFile | undefined | null) { this.chatDialog.hide(); if (!this.currentChat || !Types.isString(content)) { return; } this.currentChat.resolve(content); this.currentChat = undefined; } public insertAssets(assets: ReadonlyArray) { this.assetsDialog.hide(); if (!this.currentAssets) { return; } const items = assets.map(a => this.buildAsset(a)); this.currentAssets.resolve(items); this.currentAssets = undefined; } public insertContents(contents: ReadonlyArray) { this.contentsDialog.hide(); if (!this.currentContents) { return; } const items = contents.map(c => this.buildContent(c)); this.currentContents.resolve(items); this.currentContents = undefined; } private uploadFiles(requests: UploadRequest[]) { const uploadFile = (request: UploadRequest) => { return new Promise((resolve, reject) => { this.assetUploader.uploadFile(request.file, this.folderId) .subscribe({ next: value => { if (Types.is(value, AssetDto)) { resolve(this.buildAsset(value)); } else { request.progress(value / 100); } }, error: reject, }); }); }; return requests.map(r => () => uploadFile(r)); } private buildAsset(asset: AssetDto): Asset { return { ...asset, src: asset.fullUrl(this.apiUrl) }; } private buildContent(content: ContentDto): Content { return { ...content, title: buildContentTitle(content, this.language) }; } public closeAssetDialog() { this.assetId.next(null); } } function buildContentTitle(content: ContentDto, language: LanguageDto) { const name = content.referenceFields .map(f => getContentValue(content, language, f, false)) .map(v => v.formatted) .defined() .join(', '); return name || 'Content'; } class ResolvablePromise { private resolver?: (value: T) => void; public readonly promise = new Promise(resolve => { this.resolver = resolve; }); public resolve(value: T) { this.resolver?.(value); } }