Start new WYSIWYG node editor
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2020-10-13 11:57:56 -05:00
parent 35eb824b45
commit 8a9f6d508e
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
8 changed files with 425 additions and 249 deletions

View File

@ -26,6 +26,8 @@ import {CurrencyRendererComponent} from './editor/database/renderers/currency-re
import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component'; import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component';
import {SearchComponent} from './search/Search.component'; import {SearchComponent} from './search/Search.component';
import {NormComponent} from './nodes/norm/norm.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
HostComponent, HostComponent,
@ -49,6 +51,8 @@ import {SearchComponent} from './search/Search.component';
CurrencyRendererComponent, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
NormComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -79,6 +83,8 @@ import {SearchComponent} from './search/Search.component';
CurrencyRendererComponent, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
NormComponent,
], ],
exports: [ exports: [
HostComponent, HostComponent,
@ -102,6 +108,8 @@ import {SearchComponent} from './search/Search.component';
CurrencyRendererComponent, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
NormComponent,
] ]
}) })
export class ComponentsModule {} export class ComponentsModule {}

View File

@ -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<boolean>;
public needsSave(): boolean | Promise<boolean> {
return false;
}
public needsLoad(): boolean | Promise<boolean> {
return false;
}
public performSave(): void | Promise<void> {}
public performLoad(): void | Promise<void> {}
}

View File

@ -0,0 +1,66 @@
<div class="container"
(focusin)="onFocusIn($event)"
(focusout)="onFocusOut($event)">
<div class="toolbar-base" *ngIf="isFocused">
<div class="toolbar-button" title="Bold">
<i class="icon fa fa-bold"></i>
</div>
<div class="toolbar-button" title="Italic">
<i class="icon fa fa-italic"></i>
</div>
<div class="toolbar-button" title="Underline">
<i class="icon fa fa-underline"></i>
</div>
<div class="toolbar-button" title="Strikethrough">
<i class="icon fa fa-strikethrough"></i>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-button" title="Align Right">
<i class="icon fa fa-align-right"></i>
</div>
<div class="toolbar-button" title="Align Center">
<i class="icon fa fa-align-center"></i>
</div>
<div class="toolbar-button" title="Align Left">
<i class="icon fa fa-align-left"></i>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-button" title="Undo">
<i class="icon fa fa-undo"></i>
</div>
<div class="toolbar-button" title="Redo">
<i class="icon fa fa-redo"></i>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-button" title="Increase Heading Level">
<i class="icon fa fa-heading"></i>
<i class="icon fa fa-long-arrow-alt-up"></i>
</div>
<div class="toolbar-button" title="Decrease Heading Level">
<i class="icon fa fa-heading"></i>
<i class="icon fa fa-long-arrow-alt-down"></i>
</div>
<div class="toolbar-button" title="Format Monospace">
<i class="icon fa fa-code"></i>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-button" title="Begin Bulleted List">
<i class="icon fa fa-list-ul"></i>
</div>
</div>
<div
class="editable-base"
[ngClass]="isFocused ? 'focused' : ''"
contenteditable
>
Content editable!
</div>
</div>

View File

@ -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;
}
}

View File

@ -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<boolean> {
return false; // TODO implement
}
onFocusIn(event: MouseEvent) {
this.isFocused = true;
}
onFocusOut(event: MouseEvent) {
this.isFocused = false;
}
}

View File

@ -1,47 +1,61 @@
<ng-container> <ng-container>
<ion-header (keydown)="onEditorKeydown($event)"> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-menu-button></ion-menu-button> <ion-menu-button></ion-menu-button>
</ion-buttons> </ion-buttons>
<ion-title #titleBar> <ion-title #titleBar>
<div contenteditable="true"> {{ pageRecord.Name }} </div> <ion-input
[(ngModel)]="pageName"
placeholder="Click to edit page name..."
class="title-input"
></ion-input>
</ion-title> </ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content (keydown)="onEditorKeydown($event)"> <ion-content>
<ng-container> <ng-container>
<div class="editor-root ion-padding"> <div class="editor-root ion-padding">
<div <div class="host-container" style="display: flex;">
*ngFor="let record of hostRecords; let i = index" <editor-norm style="flex: 1;"></editor-norm>
class="host-container" style="display: flex;"
(mouseenter)="makeVisible(i)"
(mouseleave)="makeInvisible(i)"
>
<ion-button fill="invisible" color="primary" (click)="onOptionsClick($event, i)" *ngIf="pageRecord.level !== 'view'">
<ion-icon
name="options"
color="medium"
[ngClass]="{'invisible': !buttonIsVisible(i)}"
></ion-icon>
</ion-button>
<editor-host
style="width: 100%;"
#editorHosts
[page]="pageRecord"
[record]="hostRecords[i]"
(recordChange)="onHostRecordChange($event, i)"
(newHostRequested)="onNewHostRequested($event)"
(destroyHostRequested)="onDestroyHostRequested($event)"
(saveHostRequested)="onSaveClick()">
</editor-host>
</div> </div>
</div> </div>
<div class="editor-buttons" style="margin-bottom: 50px;" *ngIf="pageRecord.level !== 'view'">
<ion-button (click)="onAddClick($event)" class="ion-padding ion-margin-start" fill="outline" color="medium">Add Node</ion-button>
<ion-button (click)="onSaveClick()" class="ion-padding" fill="outline" color="medium">Save</ion-button>
</div>
</ng-container> </ng-container>
</ion-content> </ion-content>
<!-- <ion-content (keydown)="onEditorKeydown($event)">-->
<!-- <ng-container>-->
<!-- <div class="editor-root ion-padding">-->
<!-- <div-->
<!-- *ngFor="let record of hostRecords; let i = index"-->
<!-- class="host-container" style="display: flex;"-->
<!-- (mouseenter)="makeVisible(i)"-->
<!-- (mouseleave)="makeInvisible(i)"-->
<!-- >-->
<!-- <ion-button fill="invisible" color="primary" (click)="onOptionsClick($event, i)" *ngIf="pageRecord.level !== 'view'">-->
<!-- <ion-icon-->
<!-- name="options"-->
<!-- color="medium"-->
<!-- [ngClass]="{'invisible': !buttonIsVisible(i)}"-->
<!-- ></ion-icon>-->
<!-- </ion-button>-->
<!-- <editor-host-->
<!-- style="width: 100%;"-->
<!-- #editorHosts-->
<!-- [page]="pageRecord"-->
<!-- [record]="hostRecords[i]"-->
<!-- (recordChange)="onHostRecordChange($event, i)"-->
<!-- (newHostRequested)="onNewHostRequested($event)"-->
<!-- (destroyHostRequested)="onDestroyHostRequested($event)"-->
<!-- (saveHostRequested)="onSaveClick()">-->
<!-- </editor-host>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="editor-buttons" style="margin-bottom: 50px;" *ngIf="pageRecord.level !== 'view'">-->
<!-- <ion-button (click)="onAddClick($event)" class="ion-padding ion-margin-start" fill="outline" color="medium">Add Node</ion-button>-->
<!-- <ion-button (click)="onSaveClick()" class="ion-padding" fill="outline" color="medium">Save</ion-button>-->
<!-- </div>-->
<!-- </ng-container>-->
<!-- </ion-content>-->
</ng-container> </ng-container>

View File

@ -5,3 +5,7 @@ ion-icon {
ion-icon.invisible { ion-icon.invisible {
opacity: 0; opacity: 0;
} }
.title-input {
}

View File

@ -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 HostRecord from '../../structures/HostRecord';
import PageRecord from '../../structures/PageRecord'; import PageRecord from '../../structures/PageRecord';
import {PageService} from '../../service/page.service'; import {PageService} from '../../service/page.service';
@ -13,19 +13,15 @@ import {HostOptionsComponent} from '../../components/editor/host-options/host-op
styleUrls: ['./editor.page.scss'], styleUrls: ['./editor.page.scss'],
}) })
export class EditorPage implements OnInit { export class EditorPage implements OnInit {
public hostRecords: Array<HostRecord> = [new HostRecord('Click to edit page...')]; // @ViewChildren('editorHosts') editorHosts;
public pageRecord: PageRecord = new PageRecord(); // @ViewChild('titleBar') titleBar;
public pageId: string;
public visibleButtons: Array<number> = [];
@ViewChildren('editorHosts') editorHosts; @Input() pageId: string;
@ViewChild('titleBar') titleBar; public pageName = '';
constructor( constructor(
protected pages: PageService,
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected router: Router, protected router: Router,
protected popover: PopoverController,
protected loader: LoadingController, protected loader: LoadingController,
) { ) {
this.route.params.subscribe(params => { this.route.params.subscribe(params => {
@ -35,215 +31,215 @@ export class EditorPage implements OnInit {
ngOnInit() {} ngOnInit() {}
buttonIsVisible(index) { // buttonIsVisible(index) {
return this.visibleButtons.includes(index); // return this.visibleButtons.includes(index);
} // }
//
makeVisible(index) { // makeVisible(index) {
if ( !this.buttonIsVisible(index) ) { // if ( !this.buttonIsVisible(index) ) {
this.visibleButtons.push(index); // this.visibleButtons.push(index);
} // }
} // }
//
makeInvisible(index) { // makeInvisible(index) {
this.visibleButtons = this.visibleButtons.filter(x => x !== index); // this.visibleButtons = this.visibleButtons.filter(x => x !== index);
} // }
//
ionViewDidEnter() { // ionViewDidEnter() {
if ( this.pageId ) { // if ( this.pageId ) {
this.pages.load(this.pageId).subscribe(pageRecord => { // this.pages.load(this.pageId).subscribe(pageRecord => {
this.pageRecord = pageRecord; // this.pageRecord = pageRecord;
this.pages.get_nodes(pageRecord).subscribe((hosts: Array<HostRecord>) => { // this.pages.get_nodes(pageRecord).subscribe((hosts: Array<HostRecord>) => {
this.hostRecords = hosts; // this.hostRecords = hosts;
if ( !pageRecord.isViewOnly() ) { // if ( !pageRecord.isViewOnly() ) {
this.onSaveClick(); // this.onSaveClick();
} // }
}); // });
}); // });
} else { // } else {
this.router.navigate(['/home']); // this.router.navigate(['/home']);
} // }
} // }
//
onHostRecordChange($event, i) { // onHostRecordChange($event, i) {
if ( !this.pageRecord.isViewOnly() ) { // if ( !this.pageRecord.isViewOnly() ) {
this.hostRecords[i] = $event; // this.hostRecords[i] = $event;
} // }
} // }
//
async onAddClick($event) { // async onAddClick($event) {
if ( this.pageRecord.isViewOnly() ) { // if ( this.pageRecord.isViewOnly() ) {
return; // return;
} // }
//
const popover = await this.popover.create({ // const popover = await this.popover.create({
component: NodePickerComponent, // component: NodePickerComponent,
event: $event, // event: $event,
}); // });
//
popover.onDidDismiss().then(arg => { // popover.onDidDismiss().then(arg => {
const defValue = this.getDefaultValue(arg.data); // const defValue = this.getDefaultValue(arg.data);
const hostRec = new HostRecord(defValue); // const hostRec = new HostRecord(defValue);
hostRec.type = arg.data; // hostRec.type = arg.data;
hostRec.PageId = this.pageRecord.UUID; // hostRec.PageId = this.pageRecord.UUID;
//
if ( hostRec.type === 'ul' ) { // if ( hostRec.type === 'ul' ) {
hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); // hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]);
} // }
//
this.hostRecords.push(hostRec); // this.hostRecords.push(hostRec);
if ( hostRec.isNorm() ) { // if ( hostRec.isNorm() ) {
setTimeout(() => { // setTimeout(() => {
this.editorHosts.toArray().reverse()[0].takeFocus(); // this.editorHosts.toArray().reverse()[0].takeFocus();
}, 0); // }, 0);
} else { // } else {
this.onSaveClick(); // this.onSaveClick();
} // }
}); // });
//
await popover.present(); // await popover.present();
} // }
//
getDefaultValue(type: string) { // getDefaultValue(type: string) {
if ( type === 'paragraph' ) { // if ( type === 'paragraph' ) {
return ''; // return '';
} else if ( type === 'header1' ) { // } else if ( type === 'header1' ) {
return '# '; // return '# ';
} else if ( type === 'header2' ) { // } else if ( type === 'header2' ) {
return '## '; // return '## ';
} else if ( type === 'header3' ) { // } else if ( type === 'header3' ) {
return '### '; // return '### ';
} else if ( type === 'header4' ) { // } else if ( type === 'header4' ) {
return '#### '; // return '#### ';
} else if ( type === 'block_code' ) { // } else if ( type === 'block_code' ) {
return '```'; // return '```';
} else if ( type === 'click_link' ) { // } else if ( type === 'click_link' ) {
return 'https://'; // return 'https://';
} else if ( type === 'page_sep' ) { // } else if ( type === 'page_sep' ) {
return '==='; // return '===';
} else { // } else {
return ''; // return '';
} // }
} // }
//
onNewHostRequested($event) { // onNewHostRequested($event) {
if ( this.pageRecord.isViewOnly() ) { // if ( this.pageRecord.isViewOnly() ) {
return; // return;
} // }
//
const insertAfter = this.getIndexFromRecord($event.record); // const insertAfter = this.getIndexFromRecord($event.record);
const record = new HostRecord(''); // const record = new HostRecord('');
const newHosts = [] // const newHosts = []
this.hostRecords.forEach((rec, i) => { // this.hostRecords.forEach((rec, i) => {
newHosts.push(rec); // newHosts.push(rec);
if ( i === insertAfter ) { // if ( i === insertAfter ) {
newHosts.push(record); // newHosts.push(record);
} // }
}) // })
//
this.hostRecords = newHosts; // this.hostRecords = newHosts;
//
setTimeout(() => { // setTimeout(() => {
this.editorHosts.toArray()[insertAfter + 1].takeFocus(); // this.editorHosts.toArray()[insertAfter + 1].takeFocus();
}, 0); // }, 0);
} // }
//
onDestroyHostRequested($event) { // onDestroyHostRequested($event) {
if ( this.pageRecord.isViewOnly() ) { // if ( this.pageRecord.isViewOnly() ) {
return; // return;
} // }
//
let removedIndex = 0; // let removedIndex = 0;
const newHostRecords = this.editorHosts.filter((host, i) => { // const newHostRecords = this.editorHosts.filter((host, i) => {
if ( $event.record === host.record ) { // if ( $event.record === host.record ) {
removedIndex = i; // removedIndex = i;
} // }
return host.record !== $event.record; // return host.record !== $event.record;
}); // });
//
const removedHost = this.editorHosts[removedIndex]; // const removedHost = this.editorHosts[removedIndex];
//
const hostRecords = newHostRecords.map(host => host.record); // const hostRecords = newHostRecords.map(host => host.record);
this.hostRecords = hostRecords; // this.hostRecords = hostRecords;
//
setTimeout(() => { // setTimeout(() => {
let focusIndex; // let focusIndex;
if ( removedIndex === 0 && this.editorHosts.toArray().length ) { // if ( removedIndex === 0 && this.editorHosts.toArray().length ) {
focusIndex = 0; // focusIndex = 0;
} else if ( removedIndex !== 0 ) { // } else if ( removedIndex !== 0 ) {
focusIndex = removedIndex - 1; // focusIndex = removedIndex - 1;
} // }
//
if ( focusIndex >= 0 ) { // if ( focusIndex >= 0 ) {
this.editorHosts.toArray()[focusIndex].takeFocus(false); // this.editorHosts.toArray()[focusIndex].takeFocus(false);
} // }
}, 0); // }, 0);
} // }
//
protected getIndexFromRecord(record) { // protected getIndexFromRecord(record) {
let index; // let index;
this.editorHosts.toArray().forEach((host, i) => { // this.editorHosts.toArray().forEach((host, i) => {
if ( host.record === record ) { // if ( host.record === record ) {
index = i; // index = i;
} // }
}); // });
return index; // return index;
} // }
//
onSaveClick() { // onSaveClick() {
if ( this.pageRecord.isViewOnly() ) { // if ( this.pageRecord.isViewOnly() ) {
return; // return;
} // }
//
this.loader.create({message: 'Saving changes...'}).then(loader => { // this.loader.create({message: 'Saving changes...'}).then(loader => {
loader.present().then(() => { // loader.present().then(() => {
this.pageRecord.Name = this.titleBar.el.innerText.trim(); // this.pageRecord.Name = this.titleBar.el.innerText.trim();
//
// First, save the page record itself // // First, save the page record itself
this.pages.save(this.pageRecord).subscribe(pageRecord => { // this.pages.save(this.pageRecord).subscribe(pageRecord => {
this.pageRecord = pageRecord; // this.pageRecord = pageRecord;
//
// Now, save the nodes // // Now, save the nodes
this.pages.save_nodes(pageRecord, this.hostRecords).subscribe(result => { // this.pages.save_nodes(pageRecord, this.hostRecords).subscribe(result => {
this.hostRecords = result; // this.hostRecords = result;
loader.dismiss(); // loader.dismiss();
}); // });
}); // });
}); // });
}); // });
} // }
//
async onOptionsClick($event, i) { // async onOptionsClick($event, i) {
if ( this.pageRecord.isViewOnly() ) { // if ( this.pageRecord.isViewOnly() ) {
return; // return;
} // }
//
const popover = await this.popover.create({ // const popover = await this.popover.create({
component: HostOptionsComponent, // component: HostOptionsComponent,
event: $event, // event: $event,
componentProps: { // componentProps: {
editor: this, // editor: this,
index: i, // index: i,
event: $event, // event: $event,
hostRecord: this.hostRecords[i], // hostRecord: this.hostRecords[i],
} // }
}); // });
//
popover.onDidDismiss().then((result) => { // popover.onDidDismiss().then((result) => {
if ( result.data === 'delete_node' ) { // if ( result.data === 'delete_node' ) {
$event.record = this.hostRecords[i]; // $event.record = this.hostRecords[i];
this.onDestroyHostRequested($event); // this.onDestroyHostRequested($event);
} // }
}) // })
//
await popover.present(); // await popover.present();
} // }
//
onEditorKeydown($event) { // onEditorKeydown($event) {
if ( $event.key === 's' && $event.ctrlKey ) { // if ( $event.key === 's' && $event.ctrlKey ) {
$event.preventDefault(); // $event.preventDefault();
this.onSaveClick(); // this.onSaveClick();
} // }
} // }
} }