diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 4506527ac..8ed9dafca 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -103,6 +103,7 @@ "assets.tabImage": "Image", "assets.tabMetadata": "Metadata", "assets.tabPreview": "Preview", + "assets.tabTextEditor": "Text Editor", "assets.updated": "Asset has been updated.", "assets.updateFailed": "Failed to update asset. Please reload.", "assets.updateFolderFailed": "Failed to update asset folder. Please reload.", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 20dff0ee9..a3c316938 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -103,6 +103,7 @@ "assets.tabImage": "Immagine", "assets.tabMetadata": "Metadati", "assets.tabPreview": "Preview", + "assets.tabTextEditor": "Text Editor", "assets.updated": "La risorsa è stata aggiornata.", "assets.updateFailed": "Non è stato possibile aggiornare la risorsa. Per favore ricarica.", "assets.updateFolderFailed": "Non è stato possibile aggiornare la cartella delle risorse. Per favore ricarica.", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 99ba50991..4c5f50f50 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -103,6 +103,7 @@ "assets.tabImage": "Afbeelding", "assets.tabMetadata": "Metadata", "assets.tabPreview": "Preview", + "assets.tabTextEditor": "Text Editor", "assets.updated": "Asset is bijgewerkt.", "assets.updateFailed": "Bijwerken van item is mislukt. Laad opnieuw.", "assets.updateFolderFailed": "Bijwerken van de map is mislukt. Laad opnieuw.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 4506527ac..8ed9dafca 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -103,6 +103,7 @@ "assets.tabImage": "Image", "assets.tabMetadata": "Metadata", "assets.tabPreview": "Preview", + "assets.tabTextEditor": "Text Editor", "assets.updated": "Asset has been updated.", "assets.updateFailed": "Failed to update asset. Please reload.", "assets.updateFolderFailed": "Failed to update asset folder. Please reload.", diff --git a/frontend/app-config/webpack.config.js b/frontend/app-config/webpack.config.js index ad3204b85..a2dd58b91 100644 --- a/frontend/app-config/webpack.config.js +++ b/frontend/app-config/webpack.config.js @@ -225,8 +225,9 @@ module.exports = function (env) { { from: './node_modules/tinymce/tinymce.min.js', to: 'dependencies/tinymce' }, { from: './node_modules/ace-builds/src-min/ace.js', to: 'dependencies/ace/ace.js' }, - { from: './node_modules/ace-builds/src-min/mode-*.js', to: 'dependencies/ace' }, - { from: './node_modules/ace-builds/src-min/worker-*.js', to: 'dependencies/ace' }, + { from: './node_modules/ace-builds/src-min/mode-*.js', to: 'dependencies/ace/[name].[ext]' }, + { from: './node_modules/ace-builds/src-min/worker-*.js', to: 'dependencies/ace/[name].[ext]' }, + { from: './node_modules/ace-builds/src-min/ext-modelist.js', to: 'dependencies/ace/ext/modelist.js' }, { from: './node_modules/video.js/dist/video.min.js', to: 'dependencies/videojs' }, { from: './node_modules/video.js/dist/video-js.min.css', to: 'dependencies/videojs' }, diff --git a/frontend/app/framework/angular/forms/editors/code-editor.component.ts b/frontend/app/framework/angular/forms/editors/code-editor.component.ts index de1a5bab4..edc2b8fe2 100644 --- a/frontend/app/framework/angular/forms/editors/code-editor.component.ts +++ b/frontend/app/framework/angular/forms/editors/code-editor.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { ResourceLoaderService, StatefulControlComponent, Types } from '@app/framework/internal'; import { Subject } from 'rxjs'; @@ -27,10 +27,12 @@ export const SQX_CODE_EDITOR_CONTROL_VALUE_ACCESSOR: any = { ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CodeEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, FocusComponent { +export class CodeEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, FocusComponent, OnChanges { private valueChanged = new Subject(); private aceEditor: any; - private value: string; + private value: any; + private valueString: string; + private modelist: any; @ViewChild('editor', { static: false }) public editor: ElementRef; @@ -41,6 +43,12 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im @Input() public mode = 'ace/mode/javascript'; + @Input() + public filePath: string; + + @Input() + public valueMode: 'String' | 'Json' = 'String'; + @Input() public height = 0; @@ -51,7 +59,20 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im } public writeValue(obj: string) { - this.value = Types.isString(obj) ? obj : ''; + this.value = obj; + + if (this.valueMode === 'Json') { + this.value = obj; + + try { + this.valueString = JSON.stringify(obj); + } catch (e) { + this.valueString = ''; + } + } else { + this.value = Types.isString(obj) ? obj : ''; + this.valueString = this.value; + } if (this.aceEditor) { this.setValue(this.value); @@ -72,6 +93,12 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im } } + public ngOnChanges(changes: SimpleChanges) { + if (changes['filePath'] || changes['mode']) { + this.setMode(); + } + } + public ngAfterViewInit() { this.valueChanged.pipe(debounceTime(500)) .subscribe(() => { @@ -82,14 +109,20 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im this.editor.nativeElement.style.height = `${this.height}px`; } - this.resourceLoader.loadLocalScript('dependencies/ace/ace.js').then(() => { + Promise.all([ + this.resourceLoader.loadLocalScript('dependencies/ace/ace.js'), + this.resourceLoader.loadLocalScript('dependencies/ace/ext/modelist.js') + ]).then(() => { this.aceEditor = ace.edit(this.editor.nativeElement); - this.aceEditor.getSession().setMode(this.mode); + this.modelist = ace.require('ace/ext/modelist'); + this.aceEditor.setReadOnly(this.snapshot.isDisabled); this.aceEditor.setFontSize(14); - this.setValue(this.value); + this.setDisabledState(this.snapshot.isDisabled); + this.setValue(this.valueString); + this.setMode(); this.aceEditor.on('blur', () => { this.changeValue(); @@ -105,13 +138,43 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im } private changeValue() { - const newValue = this.aceEditor.getValue(); + let newValue: any = null; + let newValueString: string; + + if (this.valueMode === 'Json') { + const isValid = this.aceEditor.getSession().getAnnotations().length === 0; + + if (isValid) { + try { + newValue = JSON.parse(this.aceEditor.getValue()); + } catch (e) { + newValue = null; + } + } + + newValueString = JSON.stringify(newValue); + } else { + newValueString = newValue = this.aceEditor.getValue(); + } - if (this.value !== newValue) { + if (this.valueString !== newValueString) { this.callChange(newValue); } this.value = newValue; + this.valueString = newValueString; + } + + private setMode() { + if (this.aceEditor) { + if (this.filePath && this.modelist) { + const mode = this.modelist.getModeForPath(this.filePath).mode; + + this.aceEditor.getSession().setMode(mode); + } else { + this.aceEditor.getSession().setMode(this.mode); + } + } } private setValue(value: string) { diff --git a/frontend/app/shared/components/assets/asset-dialog.component.html b/frontend/app/shared/components/assets/asset-dialog.component.html index 5a6c14fb3..17d458cb5 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.html +++ b/frontend/app/shared/components/assets/asset-dialog.component.html @@ -18,12 +18,17 @@ + @@ -45,6 +50,11 @@ {{ 'common.save' | sqxTranslate }} + + + @@ -162,10 +172,10 @@ -
+
-
+
-
+
-
+
+
+ + +
+ + +
+
+
+ @@ -198,7 +222,7 @@ - + diff --git a/frontend/app/shared/components/assets/asset-dialog.component.scss b/frontend/app/shared/components/assets/asset-dialog.component.scss index efd3600a9..16f0748a3 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.scss +++ b/frontend/app/shared/components/assets/asset-dialog.component.scss @@ -2,7 +2,7 @@ position: relative; } -.image { +.editor { & { @include absolute(0, 0, 0, 0); } diff --git a/frontend/app/shared/components/assets/asset-dialog.component.ts b/frontend/app/shared/components/assets/asset-dialog.component.ts index 8a6bcb004..723c04ebe 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.ts +++ b/frontend/app/shared/components/assets/asset-dialog.component.ts @@ -12,6 +12,7 @@ import { AssetsService } from '@app/shared/services/assets.service'; import { AssetPathItem, ROOT_ITEM } from '@app/shared/state/assets.state'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { AssetTextEditorComponent } from './asset-text-editor.component'; import { ImageCropperComponent } from './image-cropper.component'; import { ImageFocusPointComponent } from './image-focus-point.component'; @@ -39,6 +40,9 @@ export class AssetDialogComponent implements OnChanges { @ViewChildren(ImageFocusPointComponent) public imageFocus: QueryList; + @ViewChildren(AssetTextEditorComponent) + public textEditor: QueryList; + public path: Observable>; public isEditable = false; @@ -59,6 +63,10 @@ export class AssetDialogComponent implements OnChanges { return this.asset.type === 'Video'; } + public get isDocumentLikely() { + return this.asset.type === 'Unknown' && this.asset.fileSize < 100_000; + } + constructor( private readonly appsState: AppsState, private readonly assetsState: AssetsState, @@ -106,7 +114,19 @@ export class AssetDialogComponent implements OnChanges { return; } - this.imageCropper.first.toFile().then(file => { + this.uploadEdited(this.imageCropper.first.toFile()); + } + + public saveText() { + if (!this.isUploadable) { + return; + } + + this.uploadEdited(this.textEditor.first.toFile()); + } + + public uploadEdited(fileChange: Promise) { + fileChange.then(file => { if (file) { this.setProgress(0); diff --git a/frontend/app/shared/components/assets/asset-text-editor.component.html b/frontend/app/shared/components/assets/asset-text-editor.component.html new file mode 100644 index 000000000..624a51022 --- /dev/null +++ b/frontend/app/shared/components/assets/asset-text-editor.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/shared/components/assets/asset-text-editor.component.scss b/frontend/app/shared/components/assets/asset-text-editor.component.scss new file mode 100644 index 000000000..2019841b3 --- /dev/null +++ b/frontend/app/shared/components/assets/asset-text-editor.component.scss @@ -0,0 +1,5 @@ +:host ::ng-deep { + .editor { + @include absolute(0, 0, 0, 0); + } +} \ No newline at end of file diff --git a/frontend/app/shared/components/assets/asset-text-editor.component.ts b/frontend/app/shared/components/assets/asset-text-editor.component.ts new file mode 100644 index 000000000..17335ff23 --- /dev/null +++ b/frontend/app/shared/components/assets/asset-text-editor.component.ts @@ -0,0 +1,54 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { StatefulComponent } from '@app/framework'; + +interface Snapshot { + // The text to edit. + text?: string; +} + +@Component({ + selector: 'sqx-asset-text-editor', + styleUrls: ['./asset-text-editor.component.scss'], + templateUrl: './asset-text-editor.component.html' +}) +export class AssetTextEditorComponent extends StatefulComponent implements OnInit { + @Input() + public fileSource: string; + + @Input() + public fileName: string; + + @Input() + public mimeType: string; + + constructor(changeDetector: ChangeDetectorRef, + private readonly httpClient: HttpClient + ) { + super(changeDetector, {}); + } + + public ngOnInit() { + this.httpClient.get(this.fileSource, { responseType: 'text' }) + .subscribe(text => { + this.next({ text }); + }); + } + + public toFile(): Promise { + return new Promise(resolve => { + const blob = new Blob([this.snapshot.text || ''], { + type: this.mimeType + }); + + resolve(blob); + }); + } +} \ No newline at end of file diff --git a/frontend/app/shared/declarations.ts b/frontend/app/shared/declarations.ts index 9d1e0bf76..b2fbf79c3 100644 --- a/frontend/app/shared/declarations.ts +++ b/frontend/app/shared/declarations.ts @@ -11,6 +11,7 @@ export * from './components/assets/asset-folder-dialog.component'; export * from './components/assets/asset-folder.component'; export * from './components/assets/asset-history.component'; export * from './components/assets/asset-path.component'; +export * from './components/assets/asset-text-editor.component'; export * from './components/assets/asset-uploader.component'; export * from './components/assets/asset.component'; export * from './components/assets/assets-list.component'; diff --git a/frontend/app/shared/module.ts b/frontend/app/shared/module.ts index a1ba61c34..2917c3670 100644 --- a/frontend/app/shared/module.ts +++ b/frontend/app/shared/module.ts @@ -15,7 +15,7 @@ import { SqxFrameworkModule } from '@app/framework'; import { MentionModule } from 'angular-mentions'; import { NgxDocViewerModule } from 'ngx-doc-viewer'; import { PreviewableType } from './components/assets/pipes'; -import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsDialogState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PatternsService, PatternsState, PlansService, PlansState, QueryComponent, QueryListComponent, QueryPathComponent, ReferencesCheckboxesComponent, ReferencesDropdownComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations'; +import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsDialogState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PatternsService, PatternsState, PlansService, PlansState, QueryComponent, QueryListComponent, QueryPathComponent, ReferencesCheckboxesComponent, ReferencesDropdownComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations'; import { SearchService } from './services/search.service'; @NgModule({ @@ -37,6 +37,7 @@ import { SearchService } from './services/search.service'; AssetPreviewUrlPipe, AssetsListComponent, AssetsSelectorComponent, + AssetTextEditorComponent, AssetUploaderComponent, AssetUrlPipe, CommentComponent,