diff --git a/package-lock.json b/package-lock.json index 915c0ff..70c5219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1935,6 +1935,21 @@ "schema-utils": "^2.7.0" } }, + "@ng-stack/contenteditable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ng-stack/contenteditable/-/contenteditable-1.1.0.tgz", + "integrity": "sha512-bmu0PFWNgAw+shTDlQQV6gBiUcbp6VEwl51fGCUci5GAhKtLwYEe/biPA6Q6tsqSP4l1/XV/HUvroKek1+csOg==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz", + "integrity": "sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==" + } + } + }, "@ngtools/webpack": { "version": "10.1.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-10.1.6.tgz", diff --git a/package.json b/package.json index a646a21..a8230b0 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@ionic-native/splash-screen": "^5.0.0", "@ionic-native/status-bar": "^5.0.0", "@ionic/angular": "^5.3.5", + "@ng-stack/contenteditable": "^1.1.0", "ag-grid-angular": "^22.1.1", "ag-grid-community": "^22.1.1", "core-js": "^2.5.4", diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 77e97a3..cb93bca 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -29,4 +29,10 @@ color: var(--noded-background-code); } } + + &.files { + .tree-node-icon { + color: var(--noded-background-files); + } + } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 153e63e..82ad08a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -11,7 +11,8 @@ import {OptionPickerComponent} from './components/option-picker/option-picker.co import {OptionMenuComponent} from './components/option-menu/option-menu.component'; import {SelectorComponent} from './components/sharing/selector/selector.component'; import {SessionService} from './service/session.service'; -import {SearchComponent} from "./components/search/Search.component"; +import {SearchComponent} from './components/search/Search.component'; +import {NodeTypeIcons} from './structures/node-types'; @Component({ selector: 'app-root', @@ -64,13 +65,7 @@ export class AppComponent implements OnInit { } }; - public typeIcons = { - branch: 'fa fa-folder', - node: 'fa fa-quote-left', - page: 'fa fa-sticky-note', - db: 'fa fa-database', - code: 'fa fa-code', - }; + public typeIcons = NodeTypeIcons; public get appName(): string { return this.session.appName || 'Noded'; diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index b79e22a..5d4b644 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -1,12 +1,12 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { HostComponent } from './editor/host/host.component'; import {NodePickerComponent} from './editor/node-picker/node-picker.component'; import {IonicModule} from '@ionic/angular'; import {DatabaseComponent} from './editor/database/database.component'; import {AgGridModule} from 'ag-grid-angular'; import {ColumnsComponent} from './editor/database/columns/columns.component'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {ContenteditableModule} from '@ng-stack/contenteditable'; import {CodeComponent} from './editor/code/code.component'; import {MonacoEditorModule} from 'ngx-monaco-editor'; import {FilesComponent} from './editor/files/files.component'; @@ -26,9 +26,11 @@ import {CurrencyRendererComponent} from './editor/database/renderers/currency-re import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component'; import {SearchComponent} from './search/Search.component'; +import {NormComponent} from './nodes/norm/norm.component'; +import {DirectivesModule} from '../directives/directives.module'; + @NgModule({ declarations: [ - HostComponent, NodePickerComponent, DatabaseComponent, ColumnsComponent, @@ -49,16 +51,20 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ], imports: [ CommonModule, IonicModule, AgGridModule, FormsModule, - MonacoEditorModule + ReactiveFormsModule, + ContenteditableModule, + MonacoEditorModule, + DirectivesModule, ], entryComponents: [ - HostComponent, NodePickerComponent, DatabaseComponent, ColumnsComponent, @@ -79,9 +85,10 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ], exports: [ - HostComponent, NodePickerComponent, DatabaseComponent, ColumnsComponent, @@ -102,6 +109,8 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ] }) export class ComponentsModule {} diff --git a/src/app/components/editor/code/code.component.html b/src/app/components/editor/code/code.component.html index b6bea5f..7db4cd6 100644 --- a/src/app/components/editor/code/code.component.html +++ b/src/app/components/editor/code/code.component.html @@ -7,7 +7,7 @@ -
+
- - -  Drop Editor -  Save Changes - -
diff --git a/src/app/components/editor/code/code.component.ts b/src/app/components/editor/code/code.component.ts index d13c5b2..4d559f5 100644 --- a/src/app/components/editor/code/code.component.ts +++ b/src/app/components/editor/code/code.component.ts @@ -1,209 +1,215 @@ -import {Component, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core'; +import {Component, Input, OnInit} from '@angular/core'; import {v4} from 'uuid'; -import HostRecord from '../../../structures/HostRecord'; -import {Observable} from 'rxjs'; import {ApiService} from '../../../service/api.service'; -import {AlertController, LoadingController} from '@ionic/angular'; +import {EditorNodeContract} from '../../nodes/EditorNode.contract'; +import {EditorService} from '../../../service/editor.service'; @Component({ - selector: 'editor-code', - templateUrl: './code.component.html', - styleUrls: ['./code.component.scss'], + selector: 'editor-code', + templateUrl: './code.component.html', + styleUrls: ['./code.component.scss'], }) -export class CodeComponent implements OnInit { - @Input() readonly = false; - @Input() hostRecord: HostRecord; - @Output() hostRecordChange = new EventEmitter(); - @Output() requestParentSave = new EventEmitter(); - @Output() requestParentDelete = new EventEmitter(); - @ViewChild('theEditor') theEditor; - - public dirty = false; - public pendingSetup = true; - protected dbRecord: any = {}; - - public languageOptions: Array = [ - 'ABAP', - 'AES', - 'Apex', - 'AZCLI', - 'Bat', - 'C', - 'Cameligo', - 'Clojure', - 'CoffeeScript', - 'Cpp', - 'Csharp', - 'CSP', - 'CSS', - 'Dockerfile', - 'Fsharp', - 'Go', - 'GraphQL', - 'Handlebars', - 'HTML', - 'INI', - 'Java', - 'JavaScript', - 'JSON', - 'Kotlin', - 'LeSS', - 'Lua', - 'Markdown', - 'MiPS', - 'MSDAX', - 'MySQL', - 'Objective-C', - 'Pascal', - 'Pascaligo', - 'Perl', - 'pgSQL', - 'PHP', - 'Plaintext', - 'Postiats', - 'PowerQuery', - 'PowerShell', - 'Pug', - 'Python', - 'R', - 'Razor', - 'Redis', - 'RedShift', - 'RestructuredText', - 'Ruby', - 'Rust', - 'SB', - 'Scheme', - 'SCSS', - 'Shell', - 'SOL', - 'SQL', - 'St', - 'Swift', - 'TCL', - 'Twig', - 'TypeScript', - 'VB', - 'XML', - 'YAML', - ]; - - public editorOptions = { - language: 'javascript', - uri: v4(), - readOnly: this.readonly, - }; - public editorValue = ''; - - constructor( - protected api: ApiService, - protected loader: LoadingController, - protected alerts: AlertController, - ) { } - - ngOnInit() { - this.loader.create({message: 'Loading code...'}).then(loader => { - loader.present().then(() => { - this.getInitObservable().subscribe(() => { - this.editorOptions.language = this.dbRecord.Language; - this.editorOptions.readOnly = this.readonly; - this.onSelectChange(false); - loader.dismiss(); - }); +export class CodeComponent extends EditorNodeContract implements OnInit { + @Input() nodeId: string; + public dirty = false; + protected dbRecord: any = {}; + protected codeRefId!: string; + + public editorOptions = { + language: 'javascript', + uri: v4(), + readOnly: false, + }; + + public editorValue = ''; + public get readonly() { + return !this.node || !this.editorService.canEdit(); + } + + public languageOptions: Array = [ + 'ABAP', + 'AES', + 'Apex', + 'AZCLI', + 'Bat', + 'C', + 'Cameligo', + 'Clojure', + 'CoffeeScript', + 'Cpp', + 'Csharp', + 'CSP', + 'CSS', + 'Dockerfile', + 'Fsharp', + 'Go', + 'GraphQL', + 'Handlebars', + 'HTML', + 'INI', + 'Java', + 'JavaScript', + 'JSON', + 'Kotlin', + 'LeSS', + 'Lua', + 'Markdown', + 'MiPS', + 'MSDAX', + 'MySQL', + 'Objective-C', + 'Pascal', + 'Pascaligo', + 'Perl', + 'pgSQL', + 'PHP', + 'Plaintext', + 'Postiats', + 'PowerQuery', + 'PowerShell', + 'Pug', + 'Python', + 'R', + 'Razor', + 'Redis', + 'RedShift', + 'RestructuredText', + 'Ruby', + 'Rust', + 'SB', + 'Scheme', + 'SCSS', + 'Shell', + 'SOL', + 'SQL', + 'St', + 'Swift', + 'TCL', + 'Twig', + 'TypeScript', + 'VB', + 'XML', + 'YAML', + ]; + protected hadLoad = false; + + constructor( + public readonly editorService: EditorService, + public readonly api: ApiService, + ) { super(); } + + public isDirty(): boolean | Promise { + return this.dirty; + } + + public needsSave(): boolean | Promise { + return this.dirty; + } + + public writeChangesToNode(): void | Promise { + this.node.Value.Mode = 'code'; + this.node.Value.Value = this.codeRefId; + this.node.value = this.codeRefId; + } + + public needsLoad(): boolean | Promise { + return this.node && !this.hadLoad; + } + + public performLoad(): void | Promise { + return new Promise((res, rej) => { + if ( !this.node.Value ) { + this.node.Value = {}; + } + + if ( !this.node.Value.Value && this.editorService.canEdit() ) { + this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/create`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.node.Value.Mode = 'code'; + this.node.Value.Value = result.data.UUID; + this.node.value = result.data.UUID; + this.codeRefId = result.data.UUID; + this.editorOptions.readOnly = this.readonly; + this.onSelectChange(false); + this.hadLoad = true; + res(); + }, + error: rej, + }); + } else { + this.api.get(`/code/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.initialValue = this.dbRecord.code; + this.editorValue = this.dbRecord.code; + this.editorOptions.language = this.dbRecord.Language; + this.codeRefId = this.node.Value.Value; + this.editorOptions.readOnly = this.readonly; + this.onSelectChange(false); + this.hadLoad = true; + res(); + }, + error: rej, + }); + } }); - }); - } - - getInitObservable(): Observable { - return new Observable(sub => { - if ( this.hostRecord && this.pendingSetup ) { - if ( !this.hostRecord.Value ) { - this.hostRecord.Value = {}; + } + + public performSave(): void | Promise { + if ( !this.editorService.canEdit() ) { + return; } - if ( !this.hostRecord.Value.Value && !this.readonly ) { - this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => { - this.dbRecord = res.data; - this.hostRecord.Value.Mode = 'code'; - this.hostRecord.Value.Value = res.data.UUID; - this.hostRecord.value = res.data.UUID; - this.hostRecordChange.emit(this.hostRecord); - this.pendingSetup = false; - sub.next(true); - sub.complete(); - }); - } else { - this.api.get(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}`).subscribe(res => { - this.dbRecord = res.data; - this.editorValue = this.dbRecord.code; - this.editorOptions.language = this.dbRecord.Language; - this.pendingSetup = false; - sub.next(true); - sub.complete(); + return new Promise((res, rej) => { + this.dbRecord.code = this.editorValue; + this.dbRecord.Language = this.editorOptions.language; + + this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}`, this.dbRecord) + .subscribe({ + next: result => { + this.dbRecord = result.data; + this.editorOptions.language = this.dbRecord.Language; + this.editorValue = this.dbRecord.code; + this.dirty = false; + res(); + }, + error: rej, + }); + }); + } + + public performDelete(): void | Promise { + return new Promise((res, rej) => { + this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/delete/${this.node.Value.Value}`).subscribe({ + next: result => { + res(); + }, + error: rej, }); - } - } else { - this.pendingSetup = true; - } - }); - } - - onSaveClick() { - if ( this.readonly ) { - return; - } - - this.dbRecord.code = this.editorValue; - this.dbRecord.Language = this.editorOptions.language; - this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}`, this.dbRecord) - .subscribe(res => { - this.dbRecord = res.data; - this.editorOptions.language = this.dbRecord.Language; - this.editorValue = this.dbRecord.code; - this.dirty = false; - }); - } - - async onDropClick() { - if ( this.readonly ) { - return; - } - - const alert = await this.alerts.create({ - header: 'Are you sure?', - message: `You are about to delete this code. This action cannot be undone.`, - buttons: [ - { - text: 'Keep It', - role: 'cancel', - }, - { - text: 'Delete It', - handler: async () => { - this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/delete/${this.hostRecord.Value.Value}`) - .subscribe(res => { - this.requestParentDelete.emit(this); - }); - }, - }, - ], - }); - - await alert.present(); - } - - public onEditorModelChange($event) { - if ( this.editorValue !== this.dbRecord.code ) { - this.dirty = true; + }); } - } - onSelectChange(updateDbRecord = true) { - if ( updateDbRecord ) { - this.dbRecord.Language = this.editorOptions.language; + ngOnInit() { + this.editorService.registerNodeEditor(this.nodeId, this).then(() => { + this.editorOptions.readOnly = !this.editorService.canEdit(); + }); + } + + public onEditorModelChange($event) { + if ( this.editorValue !== this.dbRecord.code ) { + this.dirty = true; + this.editorService.triggerSave(); + } } - this.editorOptions = {...this.editorOptions}; - } + public onSelectChange(updateDbRecord = true) { + if ( updateDbRecord ) { + this.dbRecord.Language = this.editorOptions.language; + this.editorService.triggerSave(); + this.dirty = true; + } + this.editorOptions = {...this.editorOptions}; + } } diff --git a/src/app/components/editor/database/database.component.html b/src/app/components/editor/database/database.component.html index e4e90b4..b22834a 100644 --- a/src/app/components/editor/database/database.component.html +++ b/src/app/components/editor/database/database.component.html @@ -1,17 +1,15 @@
- - - + + +  Manage Columns  Insert Row  Delete Row -  Sync Records -  Drop Database
diff --git a/src/app/components/editor/database/database.component.ts b/src/app/components/editor/database/database.component.ts index 7afbe31..5687be4 100644 --- a/src/app/components/editor/database/database.component.ts +++ b/src/app/components/editor/database/database.component.ts @@ -1,7 +1,5 @@ -import {Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core'; -import HostRecord from '../../../structures/HostRecord'; +import {Component, Input, OnInit, ViewChild} from '@angular/core'; import {ApiService} from '../../../service/api.service'; -import {Observable} from 'rxjs'; import {AlertController, LoadingController, ModalController} from '@ionic/angular'; import {ColumnsComponent} from './columns/columns.component'; import {AgGridAngular} from 'ag-grid-angular'; @@ -14,18 +12,16 @@ import {DatetimeEditorComponent} from './editors/datetime/datetime-editor.compon import {DatetimeRendererComponent} from './renderers/datetime-renderer.component'; import {CurrencyRendererComponent} from './renderers/currency-renderer.component'; import {BooleanRendererComponent} from './renderers/boolean-renderer.component'; +import {EditorNodeContract} from '../../nodes/EditorNode.contract'; +import {EditorService} from '../../../service/editor.service'; @Component({ selector: 'editor-database', templateUrl: './database.component.html', styleUrls: ['./database.component.scss'], }) -export class DatabaseComponent implements OnInit { - @Input() hostRecord: HostRecord; - @Input() readonly = false; - @Output() hostRecordChange = new EventEmitter(); - @Output() requestParentSave = new EventEmitter(); - @Output() requestParentDelete = new EventEmitter(); +export class DatabaseComponent extends EditorNodeContract implements OnInit { + @Input() nodeId: string; @ViewChild('agGridElement') agGridElement: AgGridAngular; public dbRecord: any; @@ -33,36 +29,49 @@ export class DatabaseComponent implements OnInit { public dirty = false; public lastClickRow = -1; public dbName = ''; + protected dbId!: string; + + public get readonly() { + return !this.node || !this.editorService.canEdit(); + } constructor( protected api: ApiService, protected modals: ModalController, protected alerts: AlertController, protected loader: LoadingController, - ) { } + public readonly editorService: EditorService, + ) { super(); } title = 'app'; columnDefs = []; rowData = []; + public isDirty(): boolean | Promise { + return this.dirty; + } + + public needsSave(): boolean | Promise { + return this.dirty; + } + + public needsLoad(): boolean | Promise { + return this.node && this.pendingSetup; + } + + public writeChangesToNode(): void | Promise { + this.node.Value.Mode = 'database'; + } + ngOnInit() { - // this.loader.create({message: 'Loading database...'}).then(loader => { - // setTimeout(() => { - // loader.present().then(() => { - this.getInitObservable().subscribe(() => { - this.getColumnLoadObservable().subscribe(() => { - this.getDataLoadObservable().subscribe(() => { - // loader.dismiss(); - }); - }); - }); - // }); - // }, 100); - // }); + this.editorService.registerNodeEditor(this.nodeId, this).then(() => { + + }); } onCellValueChanged() { this.dirty = true; + this.editorService.triggerSave(); } async onManageColumns() { @@ -76,14 +85,7 @@ export class DatabaseComponent implements OnInit { }); modal.onDidDismiss().then(result => { - if ( result.data ) { - this.setColumns(result.data).subscribe(() => { - const endpoint = `/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}/columns` - this.api.post(endpoint, {columns: this.columnDefs}).subscribe(res => { - this.requestParentSave.emit(this); - }); - }); - } + this.setColumns(result.data); }); await modal.present(); @@ -97,6 +99,7 @@ export class DatabaseComponent implements OnInit { this.rowData.push({}); this.agGridElement.api.setRowData(this.rowData); this.dirty = true; + this.editorService.triggerSave(); } async onRemoveRow() { @@ -115,14 +118,13 @@ export class DatabaseComponent implements OnInit { { text: 'Delete It', handler: () => { - const newRows = this.rowData.filter((x, i) => { + this.rowData = this.rowData.filter((x, i) => { return i !== this.lastClickRow; }); - - this.rowData = newRows; this.agGridElement.api.setRowData(this.rowData); this.lastClickRow = -1; this.dirty = true; + this.editorService.triggerSave(); }, } ], @@ -131,152 +133,151 @@ export class DatabaseComponent implements OnInit { await alert.present(); } - async onDropDatabase() { - if ( this.readonly ) { - return; - } - - const alert = await this.alerts.create({ - header: 'Are you sure?', - message: `You are about to delete this database and all its entries. This action cannot be undone.`, - buttons: [ - { - text: 'Keep It', - role: 'cancel', - }, - { - text: 'Delete It', - handler: async () => { - this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/drop/${this.hostRecord.Value.Value}`).subscribe(); - this.requestParentDelete.emit(this); - }, - }, - ], - }); - - await alert.present(); - } onRowClicked($event) { this.lastClickRow = $event.rowIndex; } - getDataLoadObservable(): Observable { - return new Observable(sub => { - this.api.get(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}/data`).subscribe(res => { - this.rowData = res.data.map(x => x.RowData); - this.agGridElement.api.setRowData(this.rowData); - sub.next(); - sub.complete(); - }); + setColumns(data) { + this.columnDefs = data.map(x => { + x.editable = !this.readonly; + + // Set editors and renderers for different types + if ( x.Type === 'text' ) { + x.editor = 'agTextCellEditor'; + } else if ( x.Type === 'number' ) { + x.cellEditorFramework = NumericEditorComponent; + } else if ( x.Type === 'paragraph' ) { + x.cellEditorFramework = ParagraphEditorComponent; + } else if ( x.Type === 'boolean' ) { + x.cellRendererFramework = BooleanRendererComponent; + x.cellEditorFramework = BooleanEditorComponent; + } else if ( x.Type === 'select' ) { + x.cellEditorFramework = SelectEditorComponent; + } else if ( x.Type === 'multiselect' ) { + x.cellEditorFramework = MultiSelectEditorComponent; + } else if ( x.Type === 'datetime' ) { + x.cellEditorFramework = DatetimeEditorComponent; + x.cellRendererFramework = DatetimeRendererComponent; + } else if ( x.Type === 'currency' ) { + x.cellEditorFramework = NumericEditorComponent; + x.cellRendererFramework = CurrencyRendererComponent; + } else if ( x.Type === 'index' ) { + x.editable = false; + } + + return x; }); + + this.agGridElement.api.setColumnDefs(this.columnDefs); + this.dirty = true; + this.editorService.triggerSave(); } - onSyncRecords() { - if ( this.readonly ) { - return; + public async performLoad(): Promise { + if ( !this.node.Value ) { + this.node.Value = {}; } - this.loader.create({message: 'Syncing the database...'}).then(loader => { - loader.present().then(() => { - this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}/data`, this.rowData) - .subscribe(res => { - this.rowData = res.data.map(x => x.RowData); - this.agGridElement.api.setRowData(this.rowData); - - this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}/name`, - { Name: this.dbName }) - .subscribe(resp => { - this.dirty = false; - loader.dismiss(); - }); + // Load the database record itself + if ( !this.node.Value.Value && this.editorService.canEdit() ) { + await new Promise((res, rej) => { + this.api.post(`/db/${this.page.UUID}/${this.node.UUID}/create`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.dbName = result.data.Name; + this.node.Value.Mode = 'database'; + this.node.Value.Value = result.data.UUID; + this.node.value = result.data.UUID; + res(); + }, + error: rej, + }); + }); + } else { + await new Promise((res, rej) => { + this.api.get(`/db/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.dbName = result.data.Name; + res(); + }, + error: rej, }); }); + } + + // Load the columns + await new Promise((res, rej) => { + this.api.get(`/db/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}/columns`).subscribe({ + next: result => { + this.setColumns(result.data); + res(); + }, + error: rej, + }); }); - } - getColumnLoadObservable(): Observable { - return new Observable(sub => { - this.api.get(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}/columns`).subscribe(res => { - this.setColumns(res.data).subscribe(() => { - sub.next(); - sub.complete(); - }); + // Load the data + await new Promise((res, rej) => { + this.api.get(`/db/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}/data`).subscribe({ + next: result => { + this.rowData = result.data.map(x => x.RowData); + this.agGridElement.api.setRowData(this.rowData); + res(); + }, + error: rej, }); }); - } - setColumns(data): Observable { - return new Observable(sub => { - this.columnDefs = data.map(x => { - x.editable = !this.readonly; - - // Set editors and renderers for different types - if ( x.Type === 'text' ) { - x.editor = 'agTextCellEditor'; - } else if ( x.Type === 'number' ) { - x.cellEditorFramework = NumericEditorComponent; - } else if ( x.Type === 'paragraph' ) { - x.cellEditorFramework = ParagraphEditorComponent; - } else if ( x.Type === 'boolean' ) { - x.cellRendererFramework = BooleanRendererComponent; - x.cellEditorFramework = BooleanEditorComponent; - } else if ( x.Type === 'select' ) { - x.cellEditorFramework = SelectEditorComponent; - } else if ( x.Type === 'multiselect' ) { - x.cellEditorFramework = MultiSelectEditorComponent; - } else if ( x.Type === 'datetime' ) { - x.cellEditorFramework = DatetimeEditorComponent; - x.cellRendererFramework = DatetimeRendererComponent; - } else if ( x.Type === 'currency' ) { - x.cellEditorFramework = NumericEditorComponent; - x.cellRendererFramework = CurrencyRendererComponent; - } else if ( x.Type === 'index' ) { - x.editable = false; - } + this.pendingSetup = false; + this.dirty = false; + } - console.log({x}); - return x; + public performDelete(): void | Promise { + return new Promise((res, rej) => { + this.api.post(`/db/${this.page.UUID}/${this.node.UUID}/drop/${this.node.Value.Value}`).subscribe({ + next: result => { + res(); + }, + error: rej, }); + }); + } - this.agGridElement.api.setColumnDefs(this.columnDefs); + public async performSave(): Promise { + // Save the columns first + await new Promise((res, rej) => { + this.api.post(`/db/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}/columns`, {columns: this.columnDefs}).subscribe({ + next: result => { + res(); + }, + error: rej, + }); + }); - sub.next(); - sub.complete(); + // Save the data + await new Promise((res, rej) => { + this.api.post(`/db/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}/data`, this.rowData).subscribe({ + next: result => { + this.rowData = result.data.map(x => x.RowData); + this.agGridElement.api.setRowData(this.rowData); + res(); + }, + error: rej, + }); }); - } - getInitObservable(): Observable { - return new Observable(sub => { - if ( this.hostRecord.UUID && this.pendingSetup ) { - if ( !this.hostRecord.Value ) { - this.hostRecord.Value = {}; - } - if ( !this.hostRecord.Value.Value && !this.readonly ) { - this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => { - this.dbRecord = res.data; - this.dbName = res.data.Name; - this.hostRecord.Value.Mode = 'database'; - this.hostRecord.Value.Value = res.data.UUID; - this.hostRecord.value = res.data.UUID; - this.hostRecordChange.emit(this.hostRecord); - this.pendingSetup = false; - sub.next(true); - sub.complete(); - }); - } else { - this.api.get(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}`).subscribe(res => { - this.dbRecord = res.data; - this.dbName = res.data.Name; - this.pendingSetup = false; - sub.next(true); - sub.complete(); - }); - } - } else { - this.pendingSetup = true; - } + // Save the name + await new Promise((res, rej) => { + this.api.post(`/db/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}/name`, { Name: this.dbName }).subscribe({ + next: result => { + res(); + }, + error: rej, + }); }); - } + this.dirty = false; + } } diff --git a/src/app/components/editor/files/files.component.html b/src/app/components/editor/files/files.component.html index 867ecb7..654c650 100644 --- a/src/app/components/editor/files/files.component.html +++ b/src/app/components/editor/files/files.component.html @@ -4,7 +4,6 @@ Upload - Drop Files
diff --git a/src/app/components/editor/files/files.component.ts b/src/app/components/editor/files/files.component.ts index 6111d0c..fc0ce56 100644 --- a/src/app/components/editor/files/files.component.ts +++ b/src/app/components/editor/files/files.component.ts @@ -4,38 +4,106 @@ import {ApiService} from '../../../service/api.service'; import {AlertController} from '@ionic/angular'; import {Observable} from 'rxjs'; import { APP_BASE_HREF } from '@angular/common'; +import {EditorService} from '../../../service/editor.service'; +import {EditorNodeContract} from '../../nodes/EditorNode.contract'; @Component({ selector: 'editor-files', templateUrl: './files.component.html', styleUrls: ['./files.component.scss'], }) -export class FilesComponent implements OnInit { - @Input() readonly = false; - @Input() hostRecord: HostRecord; - @Output() hostRecordChange = new EventEmitter(); - @Output() requestParentSave = new EventEmitter(); - @Output() requestParentDelete = new EventEmitter(); +export class FilesComponent extends EditorNodeContract implements OnInit { + @Input() nodeId: string; @ViewChild('uploadForm') uploadForm: ElementRef; + // @Input() readonly = false; + // @Input() hostRecord: HostRecord; + // @Output() hostRecordChange = new EventEmitter(); + // @Output() requestParentSave = new EventEmitter(); + // @Output() requestParentDelete = new EventEmitter(); public fileRecords: Array = []; public pendingSetup = true; public dbRecord: any = {}; + public dirty = false; + + public get readonly() { + return !this.node || !this.editorService.canEdit(); + } constructor( protected api: ApiService, protected alerts: AlertController, + public readonly editorService: EditorService, @Inject(APP_BASE_HREF) private baseHref: string - ) { } + ) { super(); } + + public isDirty(): boolean | Promise { + return this.dirty; + } + + public writeChangesToNode(): void | Promise { + this.node.Value.Mode = 'files'; + } + + public needsLoad(): boolean | Promise { + return this.node && this.pendingSetup; + } + + public async performLoad(): Promise { + if ( !this.node.Value ) { + this.node.Value = {}; + } + + if ( !this.node.Value.Value && !this.readonly ) { + await new Promise((res, rej) => { + this.api.post(`/files/${this.page.UUID}/${this.node.UUID}/create`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.fileRecords = result.data.files; + this.node.Value.Mode = 'files'; + this.node.Value.Value = result.data.UUID; + this.node.value = result.data.UUID; + this.dirty = true; + res(); + }, + error: rej, + }); + }); + } else { + await new Promise((res, rej) => { + this.api.get(`/files/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}`).subscribe({ + next: result => { + this.dbRecord = result.data; + this.fileRecords = result.data.files; + res(); + }, + error: rej, + }); + }); + } + + this.pendingSetup = false; + } + + public async performDelete(): Promise { + await new Promise((res, rej) => { + this.api.post(`/files/${this.page.UUID}/${this.node.UUID}/delete/${this.node.Value.Value}`).subscribe({ + next: result => { + res(); + }, + error: rej, + }); + }); + } ngOnInit() { - this.getInitObservable().subscribe(() => { + this.editorService.registerNodeEditor(this.nodeId, this).then(() => { }); } getApiSubmit() { - return this.api._build_url(`file/upload/${this.hostRecord.PageId}/${this.hostRecord.UUID}/${this.hostRecord.Value.Value}`); + return this.api._build_url(`file/upload/${this.page.UUID}/${this.node.UUID}/${this.node.Value.Value}`); } onSubmitClick() { @@ -47,73 +115,11 @@ export class FilesComponent implements OnInit { } getReturnUrl() { - return `${this.baseHref}editor;id=${this.hostRecord.PageId}`; + return `${this.baseHref}editor;id=${this.page.UUID}`; } downloadFile(fileRecord) { // tslint:disable-next-line:max-line-length - window.open(this.api._build_url(`files/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}/${fileRecord._id}`), '_blank'); + window.open(this.api._build_url(`files/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}/${fileRecord._id}`), '_blank'); } - - async onDestroyClick() { - if ( this.readonly ) { - return; - } - - const alert = await this.alerts.create({ - header: 'Are you sure?', - message: 'You are about to delete these files. This action cannot be undone.', - buttons: [ - { - text: 'Keep Them', - role: 'cancel', - }, - { - text: 'Delete Them', - handler: async () => { - this.api.post(`/files/${this.hostRecord.PageId}/${this.hostRecord.UUID}/delete/${this.hostRecord.Value.Value}`) - .subscribe(res => { - this.requestParentDelete.emit(this); - }); - }, - }, - ], - }); - - await alert.present(); - } - - getInitObservable(): Observable { - return new Observable(sub => { - if ( this.hostRecord && this.pendingSetup ) { - if ( !this.hostRecord.Value ) { - this.hostRecord.Value = {}; - } - - if ( !this.hostRecord.Value.Value && !this.readonly ) { - this.api.post(`/files/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => { - this.dbRecord = res.data; - this.fileRecords = res.data.files; - this.hostRecord.Value.Mode = 'files'; - this.hostRecord.Value.Value = res.data.UUID; - this.hostRecord.value = res.data.UUID; - this.hostRecordChange.emit(this.hostRecord); - this.pendingSetup = false; - sub.next(true); - sub.complete(); - }); - } else { - this.api.get(`/files/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}`).subscribe(res => { - this.fileRecords = res.data.files; - this.pendingSetup = false; - sub.next(true); - sub.complete(); - }); - } - } else { - this.pendingSetup = true; - } - }); - } - } diff --git a/src/app/components/editor/host-options/host-options.component.html b/src/app/components/editor/host-options/host-options.component.html index 506db30..6862e6f 100644 --- a/src/app/components/editor/host-options/host-options.component.html +++ b/src/app/components/editor/host-options/host-options.component.html @@ -1,6 +1,9 @@ - - + + +
+ +
{{ menuItems[i].name }}
diff --git a/src/app/components/editor/host-options/host-options.component.ts b/src/app/components/editor/host-options/host-options.component.ts index 75cda5a..6dccbed 100644 --- a/src/app/components/editor/host-options/host-options.component.ts +++ b/src/app/components/editor/host-options/host-options.component.ts @@ -14,13 +14,33 @@ export class HostOptionsComponent implements OnInit { @Input() event: Event; @Input() hostRecord: HostRecord; - public menuItems: Array<{name: string, icon: string, value: string, type?: string}> = []; - protected possibleMenuItems: Array<{name: string, icon: string, value: string, type?: string}> = [ + public menuItems: Array<{name: string, icon?: string, icons?: string[], value: string, type?: string}> = []; + protected possibleMenuItems: Array<{name: string, icon?: string, icons?: string[], value: string, type?: string}> = [ + { + name: 'Move Up', + icons: ['fa-chevron-up'], + value: 'move_up', + }, + { + name: 'Add Node Before', + icons: ['fa-plus'], + value: 'add_before', + }, { name: 'Delete Node', - icon: 'trash', + icon: 'fa-trash', value: 'delete_node', }, + { + name: 'Add Node After', + icons: ['fa-plus'], + value: 'add_after', + }, + { + name: 'Move Down', + icons: ['fa-chevron-down'], + value: 'move_down', + }, ]; constructor( @@ -37,7 +57,7 @@ export class HostOptionsComponent implements OnInit { } } - async onSelect(value) { - await this.popover.dismiss(value); + async onSelect(event: MouseEvent, value) { + await this.popover.dismiss({event, value}); } } diff --git a/src/app/components/editor/host/host.component.html b/src/app/components/editor/host/host.component.html deleted file mode 100644 index 0ae8f5e..0000000 --- a/src/app/components/editor/host/host.component.html +++ /dev/null @@ -1,89 +0,0 @@ - -
-
-
    -
  • -
-
    -
  • -
-
-
-
-
- -
-
- -
-
- -
-
diff --git a/src/app/components/editor/host/host.component.scss b/src/app/components/editor/host/host.component.scss deleted file mode 100644 index f3a54b3..0000000 --- a/src/app/components/editor/host/host.component.scss +++ /dev/null @@ -1,64 +0,0 @@ -.host-host.header1 { - font-weight: bold; - font-size: 24pt; -} - -.host-host.header2 { - font-weight: bold; - font-size: 21pt; -} - -.host-host.header3 { - font-weight: bold; - font-size: 18pt; -} - -.host-host.header4 { - font-weight: bold; - font-size: 16pt; -} - -.host-host.block_code { - font-family: monospace; - font-size: 12pt; - background-color: #ddd; - margin-bottom: 15px; -} - -.host-host.click_link { - color: #0141b0; - cursor: pointer; - - &:hover { - text-decoration: underline; - } -} - -.hr-wrapper { - margin: 50px 100px; - - & hr { - background: #ccc; - height: 2px; - } -} - -.node-indentation-level-num-1 { - margin-left: 15px; -} - -.node-indentation-level-num-2 { - margin-left: 30px; -} - -.node-indentation-level-num-3 { - margin-left: 45px; -} - -.node-indentation-level-num-4 { - margin-left: 60px; -} - -.node-indentation-level-num-5 { - margin-left: 75px; -} diff --git a/src/app/components/editor/host/host.component.ts b/src/app/components/editor/host/host.component.ts deleted file mode 100644 index cd75c6d..0000000 --- a/src/app/components/editor/host/host.component.ts +++ /dev/null @@ -1,233 +0,0 @@ -import {Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild, ViewChildren} from '@angular/core'; -import HostRecord from '../../../structures/HostRecord'; -import PageRecord from '../../../structures/PageRecord'; - -@Component({ - selector: 'editor-host', - templateUrl: './host.component.html', - styleUrls: ['./host.component.scss'], -}) -export class HostComponent implements OnInit { - @Input() page: PageRecord; - @Input() record: HostRecord; - @Output() recordChange = new EventEmitter(); - @Output() newHostRequested = new EventEmitter(); - @Output() destroyHostRequested = new EventEmitter(); - @Output() saveHostRequested = new EventEmitter(); - @ViewChild('hostContainer') hostContainer: ElementRef; - @ViewChildren('liItems') liItems; - - public listLines: Array = []; - - constructor() { } - - ngOnInit() { - if ( this.record.type === 'ul' ) { - const values = JSON.parse(this.record.value); - values.forEach(group => this.listLines.push(group.value)); - setTimeout(() => { - values.forEach((group, i) => { - const el = this.liItems.toArray()[i].nativeElement; - el.className += ` node-indentation-level-num-${group.indentationLevel}`; - }); - }, 0); - } - } - - onRecordChange($event) { - this.recordChange.emit($event); - } - - onKeyUp($event) { - const innerText = this.hostContainer.nativeElement.innerText.trim() - if ( $event.code === 'Enter' && this.record.isNorm() && !$event.shiftKey - && ( this.record.type !== 'block_code' - || (innerText.endsWith('```') && (innerText.match(/`/g) || []).length >= 6) // TODO don't add new if cursor in block - ) - ) { - this.hostContainer.nativeElement.innerText = this.hostContainer.nativeElement.innerText.trim(); - this.newHostRequested.emit(this); - } else if ( $event.code === 'Backspace' && !this.hostContainer.nativeElement.innerText.trim() ) { - this.destroyHostRequested.emit(this); - } - - if ( innerText.startsWith('# ') ) { - this.record.type = 'header1'; - } else if ( innerText.startsWith('## ') ) { - this.record.type = 'header2'; - } else if ( innerText.startsWith('### ') ) { - this.record.type = 'header3'; - } else if ( innerText.startsWith('#### ') ) { - this.record.type = 'header4'; - } else if ( innerText.startsWith('```') ) { - this.record.type = 'block_code'; - } else if ( innerText.startsWith('http') ) { - this.record.type = 'click_link'; - } else if ( innerText === '===' ) { - this.record.type = 'page_sep'; - } else if ( innerText.startsWith('-') || innerText.startsWith(' -') ) { - this.record.type = 'ul'; - this.listLines = [this.record.value]; - setTimeout(() => { - this.focusStart(this.liItems.toArray()[0].nativeElement); - }, 0); - } - } - - onUlKeyDown($event, index) { - if ( $event.code === 'Tab' ) { - $event.preventDefault(); - const elem = this.liItems.toArray()[index]; - let currentLevel = 0; - - elem.nativeElement.className.split(' ').some(x => { - if ( x.startsWith('node-indentation-level-num-') ) { - currentLevel = Number(x.replace('node-indentation-level-num-', '')); - return true; - } - }); - - const newLevel = $event.shiftKey ? currentLevel - 1 : currentLevel + 1; - if ( newLevel <= 5 && newLevel >= 0 ) { - const existing = elem.nativeElement.className.split(' ').filter(x => !x.startsWith('node-indentation-level-num-')); - existing.push(`node-indentation-level-num-${newLevel}`); - - elem.nativeElement.className = existing.join(' '); - } - } - } - - onUlKeyUp($event, i) { - if ( $event.code === 'Enter' && !$event.shiftKey ) { - const e = this.liItems.toArray()[i].nativeElement; - e.innerText = e.innerText.trim(); - if ( this.liItems.toArray()[i].nativeElement.innerText.trim() === '' ) { - this.newHostRequested.emit(this); - } else { - this.listLines.push(''); - setTimeout(() => { - this.focusStart(this.liItems.toArray()[i + 1].nativeElement); - - let newLevel = 0; - this.liItems.toArray()[i].nativeElement.className.split(' ').some(x => { - if ( x.startsWith('node-indentation-level-num-') ) { - newLevel = Number(x.replace('node-indentation-level-num-', '')); - return true; - } - }); - - const classes = this.liItems.toArray()[i + 1].nativeElement.className - .split(' ') - .filter(x => !x.startsWith('node-indentation-level-num-')); - classes.push(`node-indentation-level-num-${newLevel}`); - this.liItems.toArray()[i + 1].nativeElement.className = classes.join(' '); - }, 0); - } - } else if ( $event.code === 'Backspace' && this.liItems.toArray()[i].nativeElement.innerText.trim() === '' ) { - const newLines = []; - this.liItems.toArray().forEach((elem, index) => { - if ( index !== i ) { - newLines.push(elem.nativeElement.innerText ? elem.nativeElement.innerText.trim() : ''); - } - }); - - this.listLines = newLines; - - if ( i === 0 && this.listLines.length === 0 ) { - this.destroyHostRequested.emit(this); - } else { - setTimeout(() => { - this.focusEnd(this.liItems.toArray()[i - 1].nativeElement); - }, 0); - } - } else if ( $event.code === 'ArrowDown' ) { - const liArr = this.liItems.toArray(); - if ( liArr.length > i + 1 ) { - setTimeout(() => { - this.focusStart(this.liItems.toArray()[i + 1].nativeElement); - }, 0); - } - } else if ( $event.code === 'ArrowUp' ) { - if ( i !== 0 ) { - setTimeout(() => { - this.focusStart(this.liItems.toArray()[i - 1].nativeElement); - }, 0); - } - } else { - const recordValue = this.liItems.toArray().map(item => { - const elem = item.nativeElement; - const value = elem.innerText.trim(); - let indentationLevel = 0; - elem.className.split(' ').some(x => { - if ( x.startsWith('node-indentation-level-num-') ) { - indentationLevel = x.replace('node-indentation-level-num-', ''); - return true; - } - }); - - return {value, indentationLevel}; - }); - - this.record.value = JSON.stringify(recordValue); - } - } - - onRequestDelete($event) { - this.destroyHostRequested.emit(this); - } - - onRequestParentSave($event) { - this.saveHostRequested.emit(this); - } - - onHostDblClick() { - if ( this.record.type === 'click_link' ) { - window.open(this.record.value.trim(), '_blank'); - } - } - - takeFocus(fromTop = true) { - if ( this.record.type === 'ul' ) { - if ( fromTop ) { - this.focusStart(this.liItems.toArray()[0].nativeElement); - } else { - this.focusEnd(this.liItems.toArray().reverse()[0].nativeElement); - } - } else { - if ( fromTop ) { - this.focusStart(this.hostContainer.nativeElement); - } else { - this.focusEnd(this.hostContainer.nativeElement); - } - } - } - - // TODO return an observable here, probably - focusEnd(item) { - const s = window.getSelection(); - const r = document.createRange(); - r.setStart(item, 0); - r.setEnd(item, 0); - s.removeAllRanges(); - s.addRange(r); - } - - // TODO return an observable here, probably - focusStart(item) { - const s = window.getSelection(); - const r = document.createRange(); - r.setStart(item, 0); - r.setEnd(item, 0); - s.removeAllRanges(); - s.addRange(r); - - setTimeout(() => { - const r2 = document.createRange(); - r2.selectNodeContents(item); - r2.collapse(false); - const s2 = window.getSelection(); - s2.removeAllRanges(); - s2.addRange(r2); - }, 0); - } -} diff --git a/src/app/components/editor/node-picker/node-picker.component.html b/src/app/components/editor/node-picker/node-picker.component.html index b14eb8d..41df36f 100644 --- a/src/app/components/editor/node-picker/node-picker.component.html +++ b/src/app/components/editor/node-picker/node-picker.component.html @@ -1,50 +1,18 @@ - - + + Paragraph - - - Heading 1 - - - - Heading 2 - - - - Heading 3 - - - - Heading 4 - - - - Unordered List - - - - Monospace Block - - - - Hyperlink - - - + + Database - - + + Code Editor - - + + Upload Files - - - Horizontal Row - diff --git a/src/app/components/editor/node-picker/node-picker.component.scss b/src/app/components/editor/node-picker/node-picker.component.scss index e69de29..a7f2b95 100644 --- a/src/app/components/editor/node-picker/node-picker.component.scss +++ b/src/app/components/editor/node-picker/node-picker.component.scss @@ -0,0 +1,27 @@ +i { + min-width: 21px; +} + +.node { + i { + color: var(--noded-background-node); + } +} + +.db { + i { + color: var(--noded-background-db); + } +} + +.code { + i { + color: var(--noded-background-code); + } +} + +.files { + i { + color: var(--noded-background-files); + } +} diff --git a/src/app/components/editor/node-picker/node-picker.component.ts b/src/app/components/editor/node-picker/node-picker.component.ts index d65146e..fa8c4db 100644 --- a/src/app/components/editor/node-picker/node-picker.component.ts +++ b/src/app/components/editor/node-picker/node-picker.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import {PopoverController} from '@ionic/angular'; +import {NodeTypeIcons} from '../../../structures/node-types'; @Component({ selector: 'editor-node-picker', @@ -8,6 +9,8 @@ import {PopoverController} from '@ionic/angular'; }) export class NodePickerComponent implements OnInit { + public typeIcons = NodeTypeIcons; + constructor( private popover: PopoverController, ) { } diff --git a/src/app/components/nodes/EditorNode.contract.ts b/src/app/components/nodes/EditorNode.contract.ts new file mode 100644 index 0000000..c5fb162 --- /dev/null +++ b/src/app/components/nodes/EditorNode.contract.ts @@ -0,0 +1,45 @@ +import PageRecord from '../../structures/PageRecord'; +import HostRecord from '../../structures/HostRecord'; + +export abstract class EditorNodeContract { + protected pageRec!: PageRecord; + protected nodeRec!: HostRecord; + protected initialValue: any; + + get page() { + return this.pageRec; + } + + set page(page: PageRecord) { + this.pageRec = page; + } + + get node() { + return this.nodeRec; + } + + set node(node: HostRecord) { + this.nodeRec = node; + } + + get identifier() { + return this.nodeRec.UUID; + } + + public abstract isDirty(): boolean | Promise; + public abstract writeChangesToNode(): void | Promise; + + public needsSave(): boolean | Promise { + return false; + } + + public needsLoad(): boolean | Promise { + return false; + } + + public performSave(): void | Promise {} + + public performLoad(): void | Promise {} + + public performDelete(): void | Promise {} +} diff --git a/src/app/components/nodes/norm/norm.component.html b/src/app/components/nodes/norm/norm.component.html new file mode 100644 index 0000000..facbac2 --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.html @@ -0,0 +1,78 @@ +
+
+ + + + + +
+ + + + + +
+ + + + +
+ + + + + + + +
+ + + + +
+
+
\ No newline at end of file diff --git a/src/app/components/nodes/norm/norm.component.scss b/src/app/components/nodes/norm/norm.component.scss new file mode 100644 index 0000000..9f6fafb --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.scss @@ -0,0 +1,36 @@ +.editable-base { + padding: 20px; + + &.focused { + background: aliceblue; + } +} + +.toolbar-base { + height: 40px; + border: 1px solid lightgray; + border-radius: 5px; + display: flex; + flex-direction: row; + + .toolbar-button { + height: calc(100% - 6px); + min-width: 30px; + display: flex; + justify-content: center; + align-items: center; + margin: 3px; + + &:hover { + background: lightgrey; + cursor: pointer; + } + } + + .toolbar-sep { + height: 100%; + width: 1px; + border: 1px solid lightgrey; + margin: 0 5px; + } +} diff --git a/src/app/components/nodes/norm/norm.component.ts b/src/app/components/nodes/norm/norm.component.ts new file mode 100644 index 0000000..5807f3d --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.ts @@ -0,0 +1,110 @@ +import {Component, HostListener, Input, OnInit, ViewChild} from '@angular/core'; +import {EditorNodeContract} from '../EditorNode.contract'; +import {EditorService} from '../../../service/editor.service'; + +@Component({ + selector: 'editor-norm', + templateUrl: './norm.component.html', + styleUrls: ['./norm.component.scss'], +}) +export class NormComponent extends EditorNodeContract implements OnInit { + @ViewChild('editable') editable; + @Input() nodeId: string; + + public isFocused = false; + public initialValue = 'Click to edit...'; + public contents = ''; + private dirtyOverride = false; + + constructor( + public readonly editorService: EditorService, + ) { + super(); + this.contents = this.initialValue; + } + + public isDirty(): boolean | Promise { + return this.dirtyOverride || this.contents !== this.initialValue; + } + + public writeChangesToNode(): void | Promise { + this.nodeRec.value = this.contents; + this.initialValue = this.contents; + } + + ngOnInit() { + this.editorService.registerNodeEditor(this.nodeId, this).then(() => { + if ( !this.node.Value ) { + this.node.Value = {}; + } + + if ( this.node.Value.Value ) { + this.initialValue = this.node.Value.Value; + } + this.contents = this.initialValue; + }); + } + + onFocusIn(event: MouseEvent) { + this.isFocused = true; + } + + onFocusOut(event: MouseEvent) { + this.isFocused = false; + } + + documentCommand(cmd: string) { + // Yes, technically this is deprecated, but it'll be poly-filled if necessary. Bite me. + document.execCommand(cmd, false, ''); + } + + onContentsChanged(contents: string) { + const innerHTML = this.editable.nativeElement.innerHTML; + if ( this.contents !== innerHTML ) { + this.contents = innerHTML; + this.editorService.triggerSave(); + } + } + + @HostListener('document:keydown.tab', ['$event']) + onIndent(event) { + event.preventDefault(); + this.documentCommand('indent'); + } + + @HostListener('document:keydown.shift.tab', ['$event']) + onOutdent(event) { + event.preventDefault(); + this.documentCommand('outdent'); + } + + @HostListener('document:keydown.control.b', ['$event']) + onBold(event) { + event.preventDefault(); + this.documentCommand('bold'); + } + + @HostListener('document:keydown.control.i', ['$event']) + onItalic(event) { + event.preventDefault(); + this.documentCommand('italic'); + } + + @HostListener('document:keydown.control.u', ['$event']) + onUnderline(event) { + event.preventDefault(); + this.documentCommand('underline'); + } + + @HostListener('document:keydown.control.z', ['$event']) + onUndo(event) { + event.preventDefault(); + this.documentCommand('undo'); + } + + @HostListener('document:keydown.control.shift.z', ['$event']) + onRedo(event) { + event.preventDefault(); + this.documentCommand('redo'); + } +} diff --git a/src/app/components/search/Search.component.scss b/src/app/components/search/Search.component.scss index aea9b27..b2d1742 100644 --- a/src/app/components/search/Search.component.scss +++ b/src/app/components/search/Search.component.scss @@ -36,6 +36,12 @@ color: var(--noded-background-code); } } + + &.files { + .search-icon { + color: var(--noded-background-files); + } + } } .search-assoc { diff --git a/src/app/components/search/Search.component.ts b/src/app/components/search/Search.component.ts index 3ec8aec..30348a3 100644 --- a/src/app/components/search/Search.component.ts +++ b/src/app/components/search/Search.component.ts @@ -3,6 +3,7 @@ import {IonInput, ModalController} from '@ionic/angular'; import {ApiService} from '../../service/api.service'; import {BehaviorSubject} from 'rxjs'; import {Router} from '@angular/router'; +import {NodeTypeIcons} from '../../structures/node-types'; export interface SearchResult { title: string; @@ -26,12 +27,7 @@ export class SearchComponent implements OnInit { @Input() query = ''; public results: BehaviorSubject = new BehaviorSubject([]); - public typeIcons = { - node: 'fa fa-quote-left', - page: 'fa fa-sticky-note', - db: 'fa fa-database', - code: 'fa fa-code', - }; + public typeIcons = NodeTypeIcons; constructor( protected modal: ModalController, diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts new file mode 100644 index 0000000..37c21dd --- /dev/null +++ b/src/app/directives/directives.module.ts @@ -0,0 +1,9 @@ +import {NgModule} from '@angular/core'; +import {DomChangeDirective} from './dom-change.directive'; + +@NgModule({ + imports: [], + exports: [DomChangeDirective], + declarations: [DomChangeDirective], +}) +export class DirectivesModule {} diff --git a/src/app/directives/dom-change.directive.ts b/src/app/directives/dom-change.directive.ts new file mode 100644 index 0000000..5cb2b0d --- /dev/null +++ b/src/app/directives/dom-change.directive.ts @@ -0,0 +1,30 @@ +import {Directive, ElementRef, EventEmitter, OnDestroy, Output} from '@angular/core'; + +@Directive({ + selector: '[appDomChange]' +}) +export class DomChangeDirective implements OnDestroy { + private changes: MutationObserver; + + @Output() + public domChange = new EventEmitter(); + + constructor(private elementRef: ElementRef) { + const element = this.elementRef.nativeElement; + + this.changes = new MutationObserver((mutations) => { + mutations.forEach(mutation => this.domChange.emit(mutation)); + }); + + this.changes.observe(element, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + } + + ngOnDestroy() { + this.changes.disconnect(); + } +} diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 0f19bb3..3c0e3ea 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -1,46 +1,55 @@ - + -
{{ pageRecord.Name }}
+
+ + +
- +
- - - - - +
+ + +
+ + + + + + + + + + + +
-
-
- Add Node - Save +
diff --git a/src/app/pages/editor/editor.page.scss b/src/app/pages/editor/editor.page.scss index 5092988..e5d6a1a 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -5,3 +5,54 @@ ion-icon { ion-icon.invisible { opacity: 0; } + +.host-icons { + padding: 5px; + color: #444; + display: flex; + flex-direction: column; +} + +.type-icon { + margin-bottom: 15px; + margin-top: 15px; + + &.node { + color: var(--noded-background-node); + } + + &.code_ref { + color: var(--noded-background-code); + } + + &.database_ref { + color: var(--noded-background-db); + } + + &.file_ref { + color: var(--noded-background-files); + } +} + +.host-add-button { + margin-top: 50px; + text-align: center; + width: 100%; + padding: 10px; + border: 1px solid lightgrey; + color: darkgrey; + border-radius: 5px; + + &:hover { + border: 1px solid darkgrey; + color: #4d4d4d; + } +} + +.save-button { + color: #777; + + i { + margin-right: 5px; + } +} diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index 82577a3..617d77c 100644 --- a/src/app/pages/editor/editor.page.ts +++ b/src/app/pages/editor/editor.page.ts @@ -1,4 +1,4 @@ -import {Component, Host, OnInit, ViewChild, ViewChildren} from '@angular/core'; +import {Component, Host, HostListener, Input, OnInit, ViewChild, ViewChildren} from '@angular/core'; import HostRecord from '../../structures/HostRecord'; import PageRecord from '../../structures/PageRecord'; import {PageService} from '../../service/page.service'; @@ -6,6 +6,8 @@ import {ActivatedRoute, Router} from '@angular/router'; import {LoadingController, PopoverController} from '@ionic/angular'; import {NodePickerComponent} from '../../components/editor/node-picker/node-picker.component'; import {HostOptionsComponent} from '../../components/editor/host-options/host-options.component'; +import {EditorService} from '../../service/editor.service'; +import {NodeTypeIcons} from '../../structures/node-types'; @Component({ selector: 'app-editor', @@ -13,20 +15,20 @@ import {HostOptionsComponent} from '../../components/editor/host-options/host-op styleUrls: ['./editor.page.scss'], }) export class EditorPage implements OnInit { - public hostRecords: Array = [new HostRecord('Click to edit page...')]; - public pageRecord: PageRecord = new PageRecord(); - public pageId: string; - public visibleButtons: Array = []; + // @ViewChildren('editorHosts') editorHosts; + // @ViewChild('titleBar') titleBar; - @ViewChildren('editorHosts') editorHosts; - @ViewChild('titleBar') titleBar; + public typeIcons = NodeTypeIcons; + + @Input() pageId: string; + public pageName = ''; constructor( - protected pages: PageService, protected route: ActivatedRoute, protected router: Router, - protected popover: PopoverController, protected loader: LoadingController, + protected popover: PopoverController, + public readonly editorService: EditorService, ) { this.route.params.subscribe(params => { this.pageId = params.id; @@ -35,215 +37,305 @@ export class EditorPage implements OnInit { ngOnInit() {} - buttonIsVisible(index) { - return this.visibleButtons.includes(index); - } - - makeVisible(index) { - if ( !this.buttonIsVisible(index) ) { - this.visibleButtons.push(index); - } - } - - makeInvisible(index) { - this.visibleButtons = this.visibleButtons.filter(x => x !== index); - } - ionViewDidEnter() { if ( this.pageId ) { - this.pages.load(this.pageId).subscribe(pageRecord => { - this.pageRecord = pageRecord; - this.pages.get_nodes(pageRecord).subscribe((hosts: Array) => { - this.hostRecords = hosts; - if ( !pageRecord.isViewOnly() ) { - this.onSaveClick(); - } - }); - }); + this.editorService.startEditing(this.pageId); } else { this.router.navigate(['/home']); } } - onHostRecordChange($event, i) { - if ( !this.pageRecord.isViewOnly() ) { - this.hostRecords[i] = $event; - } + @HostListener('document:keydown.control.s', ['$event']) + onManualSave(event) { + event.preventDefault(); + this.editorService.save(); } - async onAddClick($event) { - if ( this.pageRecord.isViewOnly() ) { + async onOptionsClick(event: MouseEvent, node: HostRecord) { + if ( !this.editorService.canEdit() ) { return; } const popover = await this.popover.create({ - component: NodePickerComponent, - event: $event, - }); - - popover.onDidDismiss().then(arg => { - const defValue = this.getDefaultValue(arg.data); - const hostRec = new HostRecord(defValue); - hostRec.type = arg.data; - hostRec.PageId = this.pageRecord.UUID; - - if ( hostRec.type === 'ul' ) { - hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); + component: HostOptionsComponent, + event, + componentProps: { + editor: this, + index: this.editorService.immutableNodes.indexOf(node), + event, + hostRecord: node, } + }); - this.hostRecords.push(hostRec); - if ( hostRec.isNorm() ) { - setTimeout(() => { - this.editorHosts.toArray().reverse()[0].takeFocus(); - }, 0); - } else { - this.onSaveClick(); + popover.onDidDismiss().then(result => { + const { event: dismissEvent , value } = result.data; + if ( value === 'delete_node' ) { + this.editorService.deleteNode(node.UUID); + } else if ( value === 'move_up' ) { + this.editorService.moveNode(node, 'up'); + } else if ( value === 'move_down' ) { + this.editorService.moveNode(node, 'down'); + } else if ( value === 'add_before' ) { + this.onAddClick(dismissEvent, 'before', node.UUID); + } else if ( value === 'add_after' ) { + this.onAddClick(dismissEvent, 'after', node.UUID); } }); await popover.present(); } - getDefaultValue(type: string) { - if ( type === 'paragraph' ) { - return ''; - } else if ( type === 'header1' ) { - return '# '; - } else if ( type === 'header2' ) { - return '## '; - } else if ( type === 'header3' ) { - return '### '; - } else if ( type === 'header4' ) { - return '#### '; - } else if ( type === 'block_code' ) { - return '```'; - } else if ( type === 'click_link' ) { - return 'https://'; - } else if ( type === 'page_sep' ) { - return '==='; - } else { - return ''; - } - } - - onNewHostRequested($event) { - if ( this.pageRecord.isViewOnly() ) { - return; - } - - const insertAfter = this.getIndexFromRecord($event.record); - const record = new HostRecord(''); - const newHosts = [] - this.hostRecords.forEach((rec, i) => { - newHosts.push(rec); - if ( i === insertAfter ) { - newHosts.push(record); - } - }) - - this.hostRecords = newHosts; - - setTimeout(() => { - this.editorHosts.toArray()[insertAfter + 1].takeFocus(); - }, 0); - } - - onDestroyHostRequested($event) { - if ( this.pageRecord.isViewOnly() ) { + async onAddClick(event: MouseEvent, position?: 'before' | 'after', positionNodeId?: string) { + if ( !this.editorService.canEdit() ) { return; } - let removedIndex = 0; - const newHostRecords = this.editorHosts.filter((host, i) => { - if ( $event.record === host.record ) { - removedIndex = i; - } - return host.record !== $event.record; + const popover = await this.popover.create({ + component: NodePickerComponent, + event, }); - const removedHost = this.editorHosts[removedIndex]; - - const hostRecords = newHostRecords.map(host => host.record); - this.hostRecords = hostRecords; - - setTimeout(() => { - let focusIndex; - if ( removedIndex === 0 && this.editorHosts.toArray().length ) { - focusIndex = 0; - } else if ( removedIndex !== 0 ) { - focusIndex = removedIndex - 1; - } - - if ( focusIndex >= 0 ) { - this.editorHosts.toArray()[focusIndex].takeFocus(false); + popover.onDidDismiss().then(result => { + console.log('adding node', result.data); + if ( !result.data ) { + return; } - }, 0); - } - - protected getIndexFromRecord(record) { - let index; - this.editorHosts.toArray().forEach((host, i) => { - if ( host.record === record ) { - index = i; - } - }); - return index; - } - - onSaveClick() { - if ( this.pageRecord.isViewOnly() ) { - return; - } - this.loader.create({message: 'Saving changes...'}).then(loader => { - loader.present().then(() => { - this.pageRecord.Name = this.titleBar.el.innerText.trim(); - - // First, save the page record itself - this.pages.save(this.pageRecord).subscribe(pageRecord => { - this.pageRecord = pageRecord; - - // Now, save the nodes - this.pages.save_nodes(pageRecord, this.hostRecords).subscribe(result => { - this.hostRecords = result; - loader.dismiss(); - }); - }); - }); + this.editorService.addNode(result.data, position, positionNodeId); }); - } - async onOptionsClick($event, i) { - if ( this.pageRecord.isViewOnly() ) { - return; - } - - const popover = await this.popover.create({ - component: HostOptionsComponent, - event: $event, - componentProps: { - editor: this, - index: i, - event: $event, - hostRecord: this.hostRecords[i], - } - }); - - popover.onDidDismiss().then((result) => { - if ( result.data === 'delete_node' ) { - $event.record = this.hostRecords[i]; - this.onDestroyHostRequested($event); - } - }) + // popover.onDidDismiss().then(arg => { + // const defValue = this.getDefaultValue(arg.data); + // const hostRec = new HostRecord(defValue); + // hostRec.type = arg.data; + // hostRec.PageId = this.pageRecord.UUID; + // + // if ( hostRec.type === 'ul' ) { + // hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); + // } + // + // this.hostRecords.push(hostRec); + // if ( hostRec.isNorm() ) { + // setTimeout(() => { + // this.editorHosts.toArray().reverse()[0].takeFocus(); + // }, 0); + // } else { + // this.onSaveClick(); + // } + // }); await popover.present(); } - onEditorKeydown($event) { - if ( $event.key === 's' && $event.ctrlKey ) { - $event.preventDefault(); - this.onSaveClick(); - } - } + // buttonIsVisible(index) { + // return this.visibleButtons.includes(index); + // } + // + // makeVisible(index) { + // if ( !this.buttonIsVisible(index) ) { + // this.visibleButtons.push(index); + // } + // } + // + // makeInvisible(index) { + // this.visibleButtons = this.visibleButtons.filter(x => x !== index); + // } + // + // ionViewDidEnter() { + // if ( this.pageId ) { + // this.pages.load(this.pageId).subscribe(pageRecord => { + // this.pageRecord = pageRecord; + // this.pages.get_nodes(pageRecord).subscribe((hosts: Array) => { + // this.hostRecords = hosts; + // if ( !pageRecord.isViewOnly() ) { + // this.onSaveClick(); + // } + // }); + // }); + // } else { + // this.router.navigate(['/home']); + // } + // } + // + // onHostRecordChange($event, i) { + // if ( !this.pageRecord.isViewOnly() ) { + // this.hostRecords[i] = $event; + // } + // } + // + // async onAddClick($event) { + // if ( this.pageRecord.isViewOnly() ) { + // return; + // } + // + // const popover = await this.popover.create({ + // component: NodePickerComponent, + // event: $event, + // }); + // + // popover.onDidDismiss().then(arg => { + // const defValue = this.getDefaultValue(arg.data); + // const hostRec = new HostRecord(defValue); + // hostRec.type = arg.data; + // hostRec.PageId = this.pageRecord.UUID; + // + // if ( hostRec.type === 'ul' ) { + // hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); + // } + // + // this.hostRecords.push(hostRec); + // if ( hostRec.isNorm() ) { + // setTimeout(() => { + // this.editorHosts.toArray().reverse()[0].takeFocus(); + // }, 0); + // } else { + // this.onSaveClick(); + // } + // }); + // + // await popover.present(); + // } + // + // getDefaultValue(type: string) { + // if ( type === 'paragraph' ) { + // return ''; + // } else if ( type === 'header1' ) { + // return '# '; + // } else if ( type === 'header2' ) { + // return '## '; + // } else if ( type === 'header3' ) { + // return '### '; + // } else if ( type === 'header4' ) { + // return '#### '; + // } else if ( type === 'block_code' ) { + // return '```'; + // } else if ( type === 'click_link' ) { + // return 'https://'; + // } else if ( type === 'page_sep' ) { + // return '==='; + // } else { + // return ''; + // } + // } + // + // onNewHostRequested($event) { + // if ( this.pageRecord.isViewOnly() ) { + // return; + // } + // + // const insertAfter = this.getIndexFromRecord($event.record); + // const record = new HostRecord(''); + // const newHosts = [] + // this.hostRecords.forEach((rec, i) => { + // newHosts.push(rec); + // if ( i === insertAfter ) { + // newHosts.push(record); + // } + // }) + // + // this.hostRecords = newHosts; + // + // setTimeout(() => { + // this.editorHosts.toArray()[insertAfter + 1].takeFocus(); + // }, 0); + // } + // + // onDestroyHostRequested($event) { + // if ( this.pageRecord.isViewOnly() ) { + // return; + // } + // + // let removedIndex = 0; + // const newHostRecords = this.editorHosts.filter((host, i) => { + // if ( $event.record === host.record ) { + // removedIndex = i; + // } + // return host.record !== $event.record; + // }); + // + // const removedHost = this.editorHosts[removedIndex]; + // + // const hostRecords = newHostRecords.map(host => host.record); + // this.hostRecords = hostRecords; + // + // setTimeout(() => { + // let focusIndex; + // if ( removedIndex === 0 && this.editorHosts.toArray().length ) { + // focusIndex = 0; + // } else if ( removedIndex !== 0 ) { + // focusIndex = removedIndex - 1; + // } + // + // if ( focusIndex >= 0 ) { + // this.editorHosts.toArray()[focusIndex].takeFocus(false); + // } + // }, 0); + // } + // + // protected getIndexFromRecord(record) { + // let index; + // this.editorHosts.toArray().forEach((host, i) => { + // if ( host.record === record ) { + // index = i; + // } + // }); + // return index; + // } + // + // onSaveClick() { + // if ( this.pageRecord.isViewOnly() ) { + // return; + // } + // + // this.loader.create({message: 'Saving changes...'}).then(loader => { + // loader.present().then(() => { + // this.pageRecord.Name = this.titleBar.el.innerText.trim(); + // + // // First, save the page record itself + // this.pages.save(this.pageRecord).subscribe(pageRecord => { + // this.pageRecord = pageRecord; + // + // // Now, save the nodes + // this.pages.save_nodes(pageRecord, this.hostRecords).subscribe(result => { + // this.hostRecords = result; + // loader.dismiss(); + // }); + // }); + // }); + // }); + // } + // + // async onOptionsClick($event, i) { + // if ( this.pageRecord.isViewOnly() ) { + // return; + // } + // + // const popover = await this.popover.create({ + // component: HostOptionsComponent, + // event: $event, + // componentProps: { + // editor: this, + // index: i, + // event: $event, + // hostRecord: this.hostRecords[i], + // } + // }); + // + // popover.onDidDismiss().then((result) => { + // if ( result.data === 'delete_node' ) { + // $event.record = this.hostRecords[i]; + // this.onDestroyHostRequested($event); + // } + // }) + // + // await popover.present(); + // } + // + // onEditorKeydown($event) { + // if ( $event.key === 's' && $event.ctrlKey ) { + // $event.preventDefault(); + // this.onSaveClick(); + // } + // } } diff --git a/src/app/service/editor.service.ts b/src/app/service/editor.service.ts new file mode 100644 index 0000000..003ee95 --- /dev/null +++ b/src/app/service/editor.service.ts @@ -0,0 +1,333 @@ +import {Injectable} from '@angular/core'; +import {ApiService} from './api.service'; +import PageRecord from '../structures/PageRecord'; +import HostRecord from '../structures/HostRecord'; +import {EditorNodeContract} from '../components/nodes/EditorNode.contract'; +import {BehaviorSubject, Subscription} from 'rxjs'; + +export class NoPageLoadedError extends Error { + constructor(msg = 'There is no page open for editing.') { + super(msg); + } +} + +export function debounce(func: (...args: any[]) => any, timeout?: number) { + let timer: number | undefined; + return (...args: any[]) => { + const next = () => func(...args); + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(next, timeout > 0 ? timeout : 300); + }; +} + +@Injectable({ + providedIn: 'root' +}) +export class EditorService { + protected currentPage?: PageRecord; + protected currentNodes: HostRecord[] = []; + protected nodeIdToEditorContract: { [key: string]: EditorNodeContract } = {}; + protected dirtyOverride = false; + protected ready$: BehaviorSubject = new BehaviorSubject(false); + protected subs: Subscription[] = []; + protected saving = false; + protected saveTriggered = false; + protected privTriggerSave = debounce(() => { + if ( this.saving ) { + this.triggerSave(); + } else { + this.save(); + } + + this.saveTriggered = false; + }, 3000); + + public triggerSave() { + this.saveTriggered = true; + this.privTriggerSave(); + } + + public get isSaving() { + return this.saving; + } + + public get willSave() { + return this.saveTriggered; + } + + public get immutableNodes(): HostRecord[] { + return [...this.currentNodes]; + } + + public get mutablePageName(): string { + if ( this.currentPage ) { + return this.currentPage.Name; + } + + return ''; + } + + public set mutablePageName(name: string) { + if ( this.currentPage && this.canEdit() ) { + if ( this.currentPage.Name !== name ) { + this.dirtyOverride = true; + } + + this.currentPage.Name = name; + } + } + + constructor( + protected api: ApiService, + ) { } + + async startEditing(pageId: string) { + if ( this.currentPage ) { + await this.stopEditing(); + } + + this.currentPage = await this.loadPage(pageId); + this.currentNodes = await this.loadNodes(pageId); + await this.ready$.next(true); + } + + async stopEditing() { + delete this.currentPage; + this.currentNodes = []; + this.nodeIdToEditorContract = {}; + this.subs.forEach(sub => sub.unsubscribe()); + this.subs = []; + this.ready$.next(false); + } + + async save() { + if ( !(await this.needsSave()) || this.saving ) { + return; + } + + this.saving = true; + const editors = Object.values(this.nodeIdToEditorContract); + + // Save all editors that handle their data independently first + await Promise.all(editors.map(async editor => { + if ( await editor.needsSave() ) { + await editor.performSave(); + } + })); + + // Tell the editors to write their state changes to the HostRecords + await Promise.all(editors.map(async editor => { + await editor.writeChangesToNode(); + })); + + await this.saveNodesAsPage(this.currentPage, this.currentNodes); + this.dirtyOverride = false; + this.saving = false; + } + + async moveNode(node: HostRecord, direction: 'up' | 'down') { + if ( !this.currentPage ) { + throw new NoPageLoadedError(); + } + + const nodeIndex = this.currentNodes.findIndex(maybeNode => maybeNode.UUID === node.UUID); + if ( nodeIndex < 0 ) { + return; + } + + if ( direction === 'up' && nodeIndex > 0 ) { + const otherIdx = nodeIndex - 1; + const otherNode = this.currentNodes[otherIdx]; + this.currentNodes[otherIdx] = this.currentNodes[nodeIndex]; + this.currentNodes[nodeIndex] = otherNode; + } else if ( direction === 'down' && nodeIndex !== (this.currentNodes.length - 1) ) { + const otherIdx = nodeIndex + 1; + const otherNode = this.currentNodes[otherIdx]; + this.currentNodes[otherIdx] = this.currentNodes[nodeIndex]; + this.currentNodes[nodeIndex] = otherNode; + } + + this.dirtyOverride = true; + this.triggerSave(); + } + + async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]): Promise { + return new Promise((res, rej) => { + const saveNodes = nodes.map(x => { + x.PageId = page.UUID; + return x.toSave(); + }); + + this.api.post(`/page/${page.UUID}/nodes/save`, saveNodes).subscribe({ + next: result => { + res(result.data.map(rec => { + const host = new HostRecord(rec.Value.Value); + host.load(rec); + return host; + })); + }, + error: rej, + }); + }); + } + + async saveNodeToPage(page: PageRecord, node: HostRecord): Promise { + return new Promise((res, rej) => { + node.PageId = page.UUID; + const nodeData = node.toSave(); + + this.api.post(`/page/${page.UUID}/nodes/save_one`, { nodeData }).subscribe({ + next: result => { + const host = new HostRecord(result.data.Value.Value); + host.load(result.data); + res(host); + }, + error: rej, + }); + }); + } + + async needsSave() { + if ( this.dirtyOverride ) { + return true; + } + + const dirties = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.isDirty())); + const needSaves = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.needsSave())); + + return dirties.some(Boolean) || needSaves.some(Boolean); + } + + async deleteNode(nodeId: string) { + if ( !this.currentPage ) { + throw new NoPageLoadedError(); + } + + const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId); + if ( !node ) { + throw new Error('Invalid node ID.'); + } + + const editor = this.nodeIdToEditorContract[nodeId]; + if ( editor ) { + await editor.performDelete(); + delete this.nodeIdToEditorContract[nodeId]; + } + + this.currentNodes = this.currentNodes.filter(x => x.UUID !== nodeId); + this.dirtyOverride = true; + this.triggerSave(); + } + + async addNode(type: 'paragraph' | 'code_ref' | 'database_ref' | 'file_ref', position?: 'before' | 'after', positionNodeId?: string) { + if ( !this.currentPage ) { + throw new NoPageLoadedError(); + } + + const baseHost = new HostRecord(); + baseHost.type = type; + baseHost.PageId = this.currentPage.UUID; + + const host = await this.saveNodeToPage(this.currentPage, baseHost); + + let placed = false; + if ( position === 'before' && positionNodeId ) { + const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId); + if ( index > -1 ) { + this.currentNodes.splice(index, 0, host); + placed = true; + } + } else if ( position === 'after' && positionNodeId ) { + const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId); + if ( index > -1 ) { + this.currentNodes.splice(index + 1, 0, host); + placed = true; + } + } + + if ( !placed ) { + this.currentNodes.push(host); + } + + this.dirtyOverride = true; + this.triggerSave(); + return host; + } + + canEdit() { + if ( !this.currentPage ) { + throw new NoPageLoadedError(); + } + + return !this.currentPage.isViewOnly(); + } + + async registerNodeEditor(nodeId: string, editor: EditorNodeContract) { + return new Promise((res, rej) => { + const sub = this.ready$.subscribe(async val => { + if ( val ) { + try { + if ( !this.currentPage ) { + return rej(new NoPageLoadedError()); + } + + const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId); + if ( !node ) { + return rej(new Error('Invalid node ID.')); + } + + editor.page = this.currentPage; + editor.node = node; + this.nodeIdToEditorContract[nodeId] = editor; + + if ( editor.needsLoad() ) { + await editor.performLoad(); + } + + res(); + } catch (e) { + rej(e); + } + } + }); + + this.subs.push(sub); + }); + } + + async unregisterNodeEditor(nodeId: string) { + if ( !this.currentPage ) { + throw new NoPageLoadedError(); + } + + delete this.nodeIdToEditorContract[nodeId]; + } + + async loadPage(pageId: string): Promise { + return new Promise((res, rej) => { + this.api.get(`/page/${pageId}`).subscribe({ + next: result => { + res(new PageRecord(result.data)); + }, + error: rej, + }); + }); + } + + async loadNodes(pageId: string): Promise { + return new Promise((res, rej) => { + this.api.get(`/page/${pageId}/nodes`).subscribe({ + next: result => { + res(result.data.map(rec => { + const host = new HostRecord(rec.Value.Value); + host.load(rec); + return host; + })); + }, + error: rej, + }); + }); + } +} diff --git a/src/app/structures/HostRecord.ts b/src/app/structures/HostRecord.ts index 3fecb07..9fca377 100644 --- a/src/app/structures/HostRecord.ts +++ b/src/app/structures/HostRecord.ts @@ -1,16 +1,19 @@ export default class HostRecord { public value = ''; - public type: 'paragraph' - |'header1'|'header2'|'header3'|'header4' - |'block_code'|'click_link'|'database_ref' - |'ul'|'code_ref'|'file_ref'|'page_sep' = 'paragraph'; + public type: 'paragraph'|'database_ref'|'code_ref'|'file_ref' = 'paragraph'; public CreatedAt: string; public PageId: string; - public UUID: string; + private privUUID: string; public UpdatedAt: string; public Value: any; + public get UUID(): string { + return this.privUUID; + } + + public set UUID(val: string) {} + constructor(value = '') { this.value = value; } @@ -21,11 +24,11 @@ export default class HostRecord { load(data: any) { this.type = data.Type; + this.privUUID = data.UUID; [ 'CreatedAt', 'PageId', - 'UUID', 'UpdatedAt', 'Value', ].forEach(field => { diff --git a/src/app/structures/node-types.ts b/src/app/structures/node-types.ts new file mode 100644 index 0000000..e7397c6 --- /dev/null +++ b/src/app/structures/node-types.ts @@ -0,0 +1,12 @@ +export const NodeTypeIcons = { + branch: 'fa fa-folder', + node: 'fa fa-quote-left', + norm: 'fa fa-quote-left', + page: 'fa fa-sticky-note', + db: 'fa fa-database', + database_ref: 'fa fa-database', + code: 'fa fa-code', + code_ref: 'fa fa-code', + file_ref: 'fa fa-archive', + files: 'fa fa-archive', +}; diff --git a/src/global.scss b/src/global.scss index cb3d4a6..5e09cc6 100644 --- a/src/global.scss +++ b/src/global.scss @@ -46,6 +46,10 @@ --noded-background-code: #FF006E; --noded-color-code: white; --noded-background-code-hover: #FF5CA3; + + --noded-background-files: #0E7B81; + --noded-color-files: white; + --noded-background-files-hover: #14AFB8; } div.picker-wrapper { @@ -64,3 +68,7 @@ div.picker-wrapper { //color: #fff; } + +hr { + background: darkgray; +}