mirror of https://github.com/Squidex/squidex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
319 lines
9.8 KiB
319 lines
9.8 KiB
/*
|
|
* 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<string | null>(null);
|
|
private editorWrapper?: SquidexEditorWrapper;
|
|
private value?: string;
|
|
private currentContents?: ResolvablePromise<any>;
|
|
private currentAssets?: ResolvablePromise<any>;
|
|
private currentChat?: ResolvablePromise<string | undefined | null>;
|
|
|
|
@Output()
|
|
public assetPluginClick = new EventEmitter<any>();
|
|
|
|
@Output()
|
|
public annotationsCreate = new EventEmitter<AnnotationSelection>();
|
|
|
|
@Output()
|
|
public annotationsUpdate = new EventEmitter<ReadonlyArray<Annotation>>();
|
|
|
|
@Output()
|
|
public annotationsSelect = new EventEmitter<ReadonlyArray<string>>();
|
|
|
|
@Input({ required: true })
|
|
public hasChatBot = false;
|
|
|
|
@Input()
|
|
public hasAnnotations = false;
|
|
|
|
@Input()
|
|
public annotations?: ReadonlyArray<Annotation> | null;
|
|
|
|
@Input()
|
|
public schemaIds?: ReadonlyArray<string>;
|
|
|
|
@Input()
|
|
public language!: LanguageDto;
|
|
|
|
@Input()
|
|
public languages!: ReadonlyArray<LanguageDto>;
|
|
|
|
@Input()
|
|
public folderId = '';
|
|
|
|
@Input({ required: true })
|
|
public classNames?: ReadonlyArray<string>;
|
|
|
|
@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<AssetDto | null>(null);
|
|
}
|
|
}),
|
|
catchError(() => of<AssetDto | null>(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<RichEditorComponent>) {
|
|
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<string | undefined | null>();
|
|
this.chatDialog.show();
|
|
|
|
return await this.currentChat.promise;
|
|
},
|
|
onSelectAssets: async () => {
|
|
if (this.snapshot.isDisabled) {
|
|
return;
|
|
}
|
|
|
|
this.currentAssets = new ResolvablePromise<any>();
|
|
this.assetsDialog.show();
|
|
|
|
return await this.currentAssets.promise;
|
|
},
|
|
onSelectContents: async () => {
|
|
if (this.snapshot.isDisabled) {
|
|
return;
|
|
}
|
|
|
|
this.currentContents = new ResolvablePromise<any>();
|
|
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<AssetDto>) {
|
|
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<ContentDto>) {
|
|
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<any>((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<T> {
|
|
private resolver?: (value: T) => void;
|
|
|
|
public readonly promise = new Promise<T>(resolve => {
|
|
this.resolver = resolve;
|
|
});
|
|
|
|
public resolve(value: T) {
|
|
this.resolver?.(value);
|
|
}
|
|
}
|
|
|