From 8a9f6d508e72abc73b7530e5c37d865a857fc77a Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 13 Oct 2020 11:57:56 -0500 Subject: [PATCH 01/16] Start new WYSIWYG node editor --- src/app/components/components.module.ts | 8 + .../components/nodes/EditorNode.contract.ts | 32 ++ .../components/nodes/norm/norm.component.html | 66 +++ .../components/nodes/norm/norm.component.scss | 33 ++ .../components/nodes/norm/norm.component.ts | 23 + src/app/pages/editor/editor.page.html | 74 +-- src/app/pages/editor/editor.page.scss | 4 + src/app/pages/editor/editor.page.ts | 434 +++++++++--------- 8 files changed, 425 insertions(+), 249 deletions(-) create mode 100644 src/app/components/nodes/EditorNode.contract.ts create mode 100644 src/app/components/nodes/norm/norm.component.html create mode 100644 src/app/components/nodes/norm/norm.component.scss create mode 100644 src/app/components/nodes/norm/norm.component.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index b79e22a..89a0bbc 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -26,6 +26,8 @@ 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'; + @NgModule({ declarations: [ HostComponent, @@ -49,6 +51,8 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ], imports: [ CommonModule, @@ -79,6 +83,8 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ], exports: [ HostComponent, @@ -102,6 +108,8 @@ import {SearchComponent} from './search/Search.component'; CurrencyRendererComponent, BooleanRendererComponent, SearchComponent, + + NormComponent, ] }) export class ComponentsModule {} diff --git a/src/app/components/nodes/EditorNode.contract.ts b/src/app/components/nodes/EditorNode.contract.ts new file mode 100644 index 0000000..7aeb9c0 --- /dev/null +++ b/src/app/components/nodes/EditorNode.contract.ts @@ -0,0 +1,32 @@ +import PageRecord from '../../structures/PageRecord'; + +export abstract class EditorNodeContract { + protected pageRec!: PageRecord; + protected nodeRec!: any; // TODO + + get page() { + return this.pageRec; + } + + set page(page: PageRecord) { + this.pageRec = page; + } + + get identifier() { + return this.nodeRec.UUID; + } + + public abstract isDirty(): boolean | Promise; + + public needsSave(): boolean | Promise { + return false; + } + + public needsLoad(): boolean | Promise { + return false; + } + + public performSave(): void | Promise {} + + public performLoad(): 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..17ad38c --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.html @@ -0,0 +1,66 @@ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+
+ + +
+
+ +
+ +
+ +
+ +
+
+
+ Content editable! +
+
\ 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..eea858f --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.scss @@ -0,0 +1,33 @@ +.editable-base { + padding: 20px; + background: aliceblue; // TODO temporary +} + +.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..218c564 --- /dev/null +++ b/src/app/components/nodes/norm/norm.component.ts @@ -0,0 +1,23 @@ +import {Component} from '@angular/core'; +import {EditorNodeContract} from '../EditorNode.contract'; + +@Component({ + selector: 'editor-norm', + templateUrl: './norm.component.html', + styleUrls: ['./norm.component.scss'], +}) +export class NormComponent extends EditorNodeContract { + public isFocused = false; + + public isDirty(): boolean | Promise { + return false; // TODO implement + } + + onFocusIn(event: MouseEvent) { + this.isFocused = true; + } + + onFocusOut(event: MouseEvent) { + this.isFocused = false; + } +} diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 0f19bb3..fdea70a 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -1,47 +1,61 @@ - + -
{{ 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..eb93e16 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -5,3 +5,7 @@ ion-icon { ion-icon.invisible { opacity: 0; } + +.title-input { + +} diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index 82577a3..25c9d1b 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, Input, OnInit, ViewChild, ViewChildren} from '@angular/core'; import HostRecord from '../../structures/HostRecord'; import PageRecord from '../../structures/PageRecord'; import {PageService} from '../../service/page.service'; @@ -13,19 +13,15 @@ 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; + @Input() pageId: string; + public pageName = ''; constructor( - protected pages: PageService, protected route: ActivatedRoute, protected router: Router, - protected popover: PopoverController, protected loader: LoadingController, ) { this.route.params.subscribe(params => { @@ -35,215 +31,215 @@ 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(); - } - }); - }); - } 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(); - } - } + // 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(); + // } + // } } From 2291b9951244cf3a435aaa6b7aa96d913cac95c2 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 13 Oct 2020 20:19:38 -0500 Subject: [PATCH 02/16] Initial editor functionality and data bindings --- package-lock.json | 15 +++++ package.json | 1 + src/app/components/components.module.ts | 9 ++- .../components/nodes/EditorNode.contract.ts | 1 + .../components/nodes/norm/norm.component.html | 66 ++++++++++--------- .../components/nodes/norm/norm.component.ts | 26 +++++++- src/app/directives/directives.module.ts | 9 +++ src/app/directives/dom-change.directive.ts | 30 +++++++++ 8 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 src/app/directives/directives.module.ts create mode 100644 src/app/directives/dom-change.directive.ts 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/components/components.module.ts b/src/app/components/components.module.ts index 89a0bbc..91ad8bf 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -6,7 +6,8 @@ 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'; @@ -27,6 +28,7 @@ import {BooleanRendererComponent} from './editor/database/renderers/boolean-rend import {SearchComponent} from './search/Search.component'; import {NormComponent} from './nodes/norm/norm.component'; +import {DirectivesModule} from '../directives/directives.module'; @NgModule({ declarations: [ @@ -59,7 +61,10 @@ import {NormComponent} from './nodes/norm/norm.component'; IonicModule, AgGridModule, FormsModule, - MonacoEditorModule + ReactiveFormsModule, + ContenteditableModule, + MonacoEditorModule, + DirectivesModule, ], entryComponents: [ HostComponent, diff --git a/src/app/components/nodes/EditorNode.contract.ts b/src/app/components/nodes/EditorNode.contract.ts index 7aeb9c0..b854218 100644 --- a/src/app/components/nodes/EditorNode.contract.ts +++ b/src/app/components/nodes/EditorNode.contract.ts @@ -3,6 +3,7 @@ import PageRecord from '../../structures/PageRecord'; export abstract class EditorNodeContract { protected pageRec!: PageRecord; protected nodeRec!: any; // TODO + protected initialValue: any; get page() { return this.pageRec; diff --git a/src/app/components/nodes/norm/norm.component.html b/src/app/components/nodes/norm/norm.component.html index 17ad38c..a0e939c 100644 --- a/src/app/components/nodes/norm/norm.component.html +++ b/src/app/components/nodes/norm/norm.component.html @@ -1,66 +1,68 @@
+ (focusout)="onFocusIn($event)">
-
+
-
+ +
-
+ +
-
+ +
+
-
- -
-
- -
-
+
+ + +
-
+
-
+ +
+
-
+
-
+ +
-
+ +
+
-
+
+
- Content editable! -
+ appDomChange + [innerHTML]="initialValue" + #editable + (domChange)="onContentsChanged($event)" + >
\ No newline at end of file diff --git a/src/app/components/nodes/norm/norm.component.ts b/src/app/components/nodes/norm/norm.component.ts index 218c564..b6491a1 100644 --- a/src/app/components/nodes/norm/norm.component.ts +++ b/src/app/components/nodes/norm/norm.component.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {EditorNodeContract} from '../EditorNode.contract'; @Component({ @@ -7,10 +7,20 @@ import {EditorNodeContract} from '../EditorNode.contract'; styleUrls: ['./norm.component.scss'], }) export class NormComponent extends EditorNodeContract { + @ViewChild('editable') editable; public isFocused = false; + public initialValue = 'Content editable now...'; + public contents = ''; + private dirtyOverride = false; + + constructor() { + super(); + console.log('norm compt', this); + this.contents = this.initialValue; + } public isDirty(): boolean | Promise { - return false; // TODO implement + return this.dirtyOverride || this.contents !== this.initialValue; } onFocusIn(event: MouseEvent) { @@ -20,4 +30,16 @@ export class NormComponent extends EditorNodeContract { 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; + } + } } 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(); + } +} From ef5c53ae04d4a7cfe91532e269bc563ad36af5fb Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 13 Oct 2020 20:50:38 -0500 Subject: [PATCH 03/16] Finish WYSIWYG editor commands and keybindings --- src/app/app.component.ts | 2 +- .../components/nodes/norm/norm.component.html | 24 +++++++--- .../components/nodes/norm/norm.component.ts | 44 ++++++++++++++++++- src/global.scss | 4 ++ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 153e63e..be9d1a5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -11,7 +11,7 @@ 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'; @Component({ selector: 'app-root', diff --git a/src/app/components/nodes/norm/norm.component.html b/src/app/components/nodes/norm/norm.component.html index a0e939c..facbac2 100644 --- a/src/app/components/nodes/norm/norm.component.html +++ b/src/app/components/nodes/norm/norm.component.html @@ -38,23 +38,33 @@
- - - +
- + +
Date: Tue, 13 Oct 2020 22:28:38 -0500 Subject: [PATCH 04/16] Add logic to the editor service for saving --- .../components/nodes/EditorNode.contract.ts | 12 +- .../components/nodes/norm/norm.component.scss | 5 +- .../components/nodes/norm/norm.component.ts | 26 ++- src/app/pages/editor/editor.page.html | 17 +- src/app/pages/editor/editor.page.ts | 18 +- src/app/service/editor.service.ts | 197 ++++++++++++++++++ src/app/structures/HostRecord.ts | 10 +- 7 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 src/app/service/editor.service.ts diff --git a/src/app/components/nodes/EditorNode.contract.ts b/src/app/components/nodes/EditorNode.contract.ts index b854218..6835d36 100644 --- a/src/app/components/nodes/EditorNode.contract.ts +++ b/src/app/components/nodes/EditorNode.contract.ts @@ -1,8 +1,9 @@ import PageRecord from '../../structures/PageRecord'; +import HostRecord from '../../structures/HostRecord'; export abstract class EditorNodeContract { protected pageRec!: PageRecord; - protected nodeRec!: any; // TODO + protected nodeRec!: HostRecord; protected initialValue: any; get page() { @@ -13,11 +14,20 @@ export abstract class EditorNodeContract { 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; diff --git a/src/app/components/nodes/norm/norm.component.scss b/src/app/components/nodes/norm/norm.component.scss index eea858f..9f6fafb 100644 --- a/src/app/components/nodes/norm/norm.component.scss +++ b/src/app/components/nodes/norm/norm.component.scss @@ -1,6 +1,9 @@ .editable-base { padding: 20px; - background: aliceblue; // TODO temporary + + &.focused { + background: aliceblue; + } } .toolbar-base { diff --git a/src/app/components/nodes/norm/norm.component.ts b/src/app/components/nodes/norm/norm.component.ts index 772f16a..2eafb13 100644 --- a/src/app/components/nodes/norm/norm.component.ts +++ b/src/app/components/nodes/norm/norm.component.ts @@ -1,21 +1,25 @@ -import {Component, HostListener, ViewChild} from '@angular/core'; +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 { +export class NormComponent extends EditorNodeContract implements OnInit { @ViewChild('editable') editable; + @Input() nodeId: string; + public isFocused = false; - public initialValue = 'Content editable now...'; + public initialValue = 'Click to edit...'; public contents = ''; private dirtyOverride = false; - constructor() { + constructor( + public readonly editorService: EditorService, + ) { super(); - console.log('norm compt', this); this.contents = this.initialValue; } @@ -23,6 +27,18 @@ export class NormComponent extends EditorNodeContract { 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(() => { + this.initialValue = this.node.Value.Value; + this.contents = this.initialValue; + }); + } + onFocusIn(event: MouseEvent) { this.isFocused = true; } diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index fdea70a..9a27a3b 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -6,7 +6,7 @@ @@ -17,10 +17,21 @@
-
- +
+ + +
+ + + + + diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index 25c9d1b..617a12a 100644 --- a/src/app/pages/editor/editor.page.ts +++ b/src/app/pages/editor/editor.page.ts @@ -1,4 +1,4 @@ -import {Component, Host, Input, 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,7 @@ 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'; @Component({ selector: 'app-editor', @@ -23,6 +24,7 @@ export class EditorPage implements OnInit { protected route: ActivatedRoute, protected router: Router, protected loader: LoadingController, + public readonly editorService: EditorService, ) { this.route.params.subscribe(params => { this.pageId = params.id; @@ -31,6 +33,20 @@ export class EditorPage implements OnInit { ngOnInit() {} + ionViewDidEnter() { + if ( this.pageId ) { + this.editorService.startEditing(this.pageId); + } else { + this.router.navigate(['/home']); + } + } + + @HostListener('document:keydown.control.s', ['$event']) + onManualSave(event) { + event.preventDefault(); + this.editorService.save(); + } + // buttonIsVisible(index) { // return this.visibleButtons.includes(index); // } diff --git a/src/app/service/editor.service.ts b/src/app/service/editor.service.ts new file mode 100644 index 0000000..80f3b47 --- /dev/null +++ b/src/app/service/editor.service.ts @@ -0,0 +1,197 @@ +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); + } +} + +@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[] = []; + + 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, + ) { + console.log('editor service', this); + } + + 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); + console.log('editing', this.currentPage); + console.log('nodes', this.currentNodes); + } + + 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()) ) { + return; + } + + 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); + } + + async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]) { + await 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(); // TODO load in returned data!! + }, + 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); + } + + 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..c6b082c 100644 --- a/src/app/structures/HostRecord.ts +++ b/src/app/structures/HostRecord.ts @@ -7,10 +7,16 @@ export default class HostRecord { 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 +27,11 @@ export default class HostRecord { load(data: any) { this.type = data.Type; + this.privUUID = data.UUID; [ 'CreatedAt', 'PageId', - 'UUID', 'UpdatedAt', 'Value', ].forEach(field => { From b0cf07ab4931d215e47e27fca46d2393be08a299 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 13 Oct 2020 22:47:59 -0500 Subject: [PATCH 05/16] Add ability to delete nodes in editor --- .../components/nodes/EditorNode.contract.ts | 2 ++ src/app/pages/editor/editor.page.html | 6 ++++ src/app/pages/editor/editor.page.scss | 15 ++++++-- src/app/pages/editor/editor.page.ts | 35 +++++++++++++++++++ src/app/service/editor.service.ts | 20 +++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/app/components/nodes/EditorNode.contract.ts b/src/app/components/nodes/EditorNode.contract.ts index 6835d36..c5fb162 100644 --- a/src/app/components/nodes/EditorNode.contract.ts +++ b/src/app/components/nodes/EditorNode.contract.ts @@ -40,4 +40,6 @@ export abstract class EditorNodeContract { public performSave(): void | Promise {} public performLoad(): void | Promise {} + + public performDelete(): void | Promise {} } diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 9a27a3b..0fbd1d3 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -22,6 +22,12 @@ style="display: flex;" *ngFor="let node of editorService.immutableNodes" > +
+ + +
diff --git a/src/app/pages/editor/editor.page.scss b/src/app/pages/editor/editor.page.scss index eb93e16..e3d4498 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -6,6 +6,17 @@ ion-icon.invisible { opacity: 0; } -.title-input { - +.host-icons { + padding: 5px; + color: #444; + display: flex; + flex-direction: column; +} + +.type-icon { + margin-bottom: 15px; + + &.norm { + color: var(--noded-background-node); + } } diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index 617a12a..96fe5cb 100644 --- a/src/app/pages/editor/editor.page.ts +++ b/src/app/pages/editor/editor.page.ts @@ -17,6 +17,15 @@ export class EditorPage implements OnInit { // @ViewChildren('editorHosts') editorHosts; // @ViewChild('titleBar') titleBar; + public typeIcons = { + branch: 'fa fa-folder', + node: 'fa fa-quote-left', + norm: 'fa fa-quote-left', + page: 'fa fa-sticky-note', + db: 'fa fa-database', + code: 'fa fa-code', + }; + @Input() pageId: string; public pageName = ''; @@ -24,6 +33,7 @@ export class EditorPage implements OnInit { protected route: ActivatedRoute, protected router: Router, protected loader: LoadingController, + protected popover: PopoverController, public readonly editorService: EditorService, ) { this.route.params.subscribe(params => { @@ -47,6 +57,31 @@ export class EditorPage implements OnInit { this.editorService.save(); } + async onOptionsClick(event: MouseEvent, node: HostRecord) { + if ( !this.editorService.canEdit() ) { + return; + } + + const popover = await this.popover.create({ + component: HostOptionsComponent, + event, + componentProps: { + editor: this, + index: this.editorService.immutableNodes.indexOf(node), + event, + hostRecord: node, + } + }); + + popover.onDidDismiss().then(result => { + if ( result.data === 'delete_node' ) { + this.editorService.deleteNode(node.UUID); + } + }); + + await popover.present(); + } + // buttonIsVisible(index) { // return this.visibleButtons.includes(index); // } diff --git a/src/app/service/editor.service.ts b/src/app/service/editor.service.ts index 80f3b47..4b4c267 100644 --- a/src/app/service/editor.service.ts +++ b/src/app/service/editor.service.ts @@ -120,6 +120,26 @@ export class EditorService { 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; + } + canEdit() { if ( !this.currentPage ) { throw new NoPageLoadedError(); From 0a6a775fdb41521a38722c40c8a7a26f2d8a3763 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 13 Oct 2020 22:59:15 -0500 Subject: [PATCH 06/16] Re-implement "add node" popover menu with colors --- .../node-picker/node-picker.component.html | 48 ++++--------------- .../node-picker/node-picker.component.scss | 21 ++++++++ .../node-picker/node-picker.component.ts | 9 ++++ src/app/pages/editor/editor.page.html | 43 ++--------------- src/app/pages/editor/editor.page.scss | 15 ++++++ src/app/pages/editor/editor.page.ts | 33 +++++++++++++ 6 files changed, 89 insertions(+), 80 deletions(-) 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..0c2183f 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..36c2c84 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,21 @@ +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); + } +} 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..8505c05 100644 --- a/src/app/components/editor/node-picker/node-picker.component.ts +++ b/src/app/components/editor/node-picker/node-picker.component.ts @@ -8,6 +8,15 @@ import {PopoverController} from '@ionic/angular'; }) export class NodePickerComponent implements OnInit { + public typeIcons = { + branch: 'fa fa-folder', + node: 'fa fa-quote-left', + norm: 'fa fa-quote-left', + page: 'fa fa-sticky-note', + db: 'fa fa-database', + code: 'fa fa-code', + }; + constructor( private popover: PopoverController, ) { } diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 0fbd1d3..7dc4619 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -32,47 +32,10 @@
+
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/src/app/pages/editor/editor.page.scss b/src/app/pages/editor/editor.page.scss index e3d4498..2b11663 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -20,3 +20,18 @@ ion-icon.invisible { color: var(--noded-background-node); } } + +.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; + } +} diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index 96fe5cb..b7dc3dd 100644 --- a/src/app/pages/editor/editor.page.ts +++ b/src/app/pages/editor/editor.page.ts @@ -82,6 +82,39 @@ export class EditorPage implements OnInit { await popover.present(); } + async onAddClick(event: MouseEvent) { + if ( !this.editorService.canEdit() ) { + return; + } + + const popover = await this.popover.create({ + component: NodePickerComponent, + 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(); + } + // buttonIsVisible(index) { // return this.visibleButtons.includes(index); // } From ae24674717b820de6ed3d088eebf2791ac396479 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 14 Oct 2020 07:37:14 -0500 Subject: [PATCH 07/16] Convert code editor to new format --- .../editor/code/code.component.html | 8 +- .../components/editor/code/code.component.ts | 391 +++++++++--------- src/app/pages/editor/editor.page.html | 7 +- src/app/pages/editor/editor.page.scss | 11 +- src/app/pages/editor/editor.page.ts | 1 + 5 files changed, 214 insertions(+), 204 deletions(-) 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..0734426 100644 --- a/src/app/components/editor/code/code.component.ts +++ b/src/app/components/editor/code/code.component.ts @@ -1,209 +1,212 @@ -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.editorOptions = {...this.editorOptions}; - } + public onSelectChange(updateDbRecord = true) { + if ( updateDbRecord ) { + this.dbRecord.Language = this.editorOptions.language; + } + this.editorOptions = {...this.editorOptions}; + } } diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 7dc4619..a1e247c 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -19,11 +19,11 @@
- + @@ -31,6 +31,9 @@ + + +
diff --git a/src/app/pages/editor/editor.page.scss b/src/app/pages/editor/editor.page.scss index 74b1270..6846c83 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -28,6 +28,10 @@ ion-icon.invisible { &.database_ref { color: var(--noded-background-db); } + + &.file_ref { + color: var(--noded-background-files); + } } .host-add-button { diff --git a/src/app/pages/editor/editor.page.ts b/src/app/pages/editor/editor.page.ts index f8bbb21..617d77c 100644 --- a/src/app/pages/editor/editor.page.ts +++ b/src/app/pages/editor/editor.page.ts @@ -97,6 +97,10 @@ export class EditorPage implements OnInit { popover.onDidDismiss().then(result => { console.log('adding node', result.data); + if ( !result.data ) { + return; + } + this.editorService.addNode(result.data, position, positionNodeId); }); diff --git a/src/app/structures/node-types.ts b/src/app/structures/node-types.ts index 703adc1..e7397c6 100644 --- a/src/app/structures/node-types.ts +++ b/src/app/structures/node-types.ts @@ -7,4 +7,6 @@ export const NodeTypeIcons = { 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 eb20474..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 { From 9a53faf3382c5e00e05b04e583418a6b7f027d2e Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 14 Oct 2020 10:04:38 -0500 Subject: [PATCH 15/16] Refactor support for the files component --- .../editor/files/files.component.html | 1 - .../editor/files/files.component.ts | 152 +++++++++--------- src/app/pages/editor/editor.page.html | 3 + 3 files changed, 82 insertions(+), 74 deletions(-) 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/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 9b26cd3..2f9c150 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -37,6 +37,9 @@ + + +
+ diff --git a/src/app/pages/editor/editor.page.scss b/src/app/pages/editor/editor.page.scss index 6846c83..e5d6a1a 100644 --- a/src/app/pages/editor/editor.page.scss +++ b/src/app/pages/editor/editor.page.scss @@ -48,3 +48,11 @@ ion-icon.invisible { color: #4d4d4d; } } + +.save-button { + color: #777; + + i { + margin-right: 5px; + } +} diff --git a/src/app/service/editor.service.ts b/src/app/service/editor.service.ts index f82cea7..003ee95 100644 --- a/src/app/service/editor.service.ts +++ b/src/app/service/editor.service.ts @@ -11,6 +11,17 @@ export class NoPageLoadedError extends Error { } } +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' }) @@ -21,6 +32,30 @@ export class EditorService { 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]; @@ -46,9 +81,7 @@ export class EditorService { constructor( protected api: ApiService, - ) { - console.log('editor service', this); - } + ) { } async startEditing(pageId: string) { if ( this.currentPage ) { @@ -58,8 +91,6 @@ export class EditorService { this.currentPage = await this.loadPage(pageId); this.currentNodes = await this.loadNodes(pageId); await this.ready$.next(true); - console.log('editing', this.currentPage); - console.log('nodes', this.currentNodes); } async stopEditing() { @@ -72,10 +103,11 @@ export class EditorService { } async save() { - if ( !(await this.needsSave()) ) { + 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 @@ -92,6 +124,7 @@ export class EditorService { await this.saveNodesAsPage(this.currentPage, this.currentNodes); this.dirtyOverride = false; + this.saving = false; } async moveNode(node: HostRecord, direction: 'up' | 'down') { @@ -117,6 +150,7 @@ export class EditorService { } this.dirtyOverride = true; + this.triggerSave(); } async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]): Promise { @@ -184,6 +218,7 @@ export class EditorService { 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) { @@ -217,6 +252,7 @@ export class EditorService { } this.dirtyOverride = true; + this.triggerSave(); return host; }