Merge pull request 'editor-refactor' (#18) from editor-refactor into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing

Reviewed-on: #18
This commit is contained in:
Garrett Mills 2020-10-14 16:42:06 +00:00
commit 8ca9b736eb
34 changed files with 1605 additions and 1122 deletions

15
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -29,4 +29,10 @@
color: var(--noded-background-code);
}
}
&.files {
.tree-node-icon {
color: var(--noded-background-files);
}
}
}

View File

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

View File

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

View File

@ -7,7 +7,7 @@
</ion-select>
</ion-item>
</ion-toolbar>
<div class="ed-wrapper" style="width: 100%; height: 480px;">
<div class="ed-wrapper" style="width: 100%; height: 540px;">
<ngx-monaco-editor style="width: 100%; height: 100%;"
[options]="editorOptions"
[(ngModel)]="editorValue"
@ -15,10 +15,4 @@
#theEditor
></ngx-monaco-editor>
</div>
<ion-toolbar *ngIf="!readonly">
<ion-buttons slot="end">
<ion-button (click)="onDropClick()"><ion-icon name="alert" color="danger"></ion-icon>&nbsp;Drop Editor</ion-button>
<ion-button (click)="onSaveClick()"><ion-icon name="save" [color]="dirty ? 'warning' : 'success'"></ion-icon>&nbsp;Save Changes</ion-button>
</ion-buttons>
</ion-toolbar>
</div>

View File

@ -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<HostRecord>();
@Output() requestParentSave = new EventEmitter<CodeComponent>();
@Output() requestParentDelete = new EventEmitter<CodeComponent>();
@ViewChild('theEditor') theEditor;
export class CodeComponent extends EditorNodeContract implements OnInit {
@Input() nodeId: string;
public dirty = false;
protected dbRecord: any = {};
protected codeRefId!: string;
public dirty = false;
public pendingSetup = true;
protected dbRecord: any = {};
public editorOptions = {
language: 'javascript',
uri: v4(),
readOnly: false,
};
public languageOptions: Array<string> = [
'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 editorValue = '';
public get readonly() {
return !this.node || !this.editorService.canEdit();
}
public editorOptions = {
language: 'javascript',
uri: v4(),
readOnly: this.readonly,
};
public editorValue = '';
public languageOptions: Array<string> = [
'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(
protected api: ApiService,
protected loader: LoadingController,
protected alerts: AlertController,
) { }
constructor(
public readonly editorService: EditorService,
public readonly api: ApiService,
) { super(); }
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();
public isDirty(): boolean | Promise<boolean> {
return this.dirty;
}
public needsSave(): boolean | Promise<boolean> {
return this.dirty;
}
public writeChangesToNode(): void | Promise<void> {
this.node.Value.Mode = 'code';
this.node.Value.Value = this.codeRefId;
this.node.value = this.codeRefId;
}
public needsLoad(): boolean | Promise<boolean> {
return this.node && !this.hadLoad;
}
public performLoad(): void | Promise<void> {
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,
});
}
});
}
public performSave(): void | Promise<void> {
if ( !this.editorService.canEdit() ) {
return;
}
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<void> {
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,
});
});
});
}
getInitObservable(): Observable<any> {
return new Observable<any>(sub => {
if ( this.hostRecord && this.pendingSetup ) {
if ( !this.hostRecord.Value ) {
this.hostRecord.Value = {};
}
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();
});
}
} 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;
}
this.editorOptions = {...this.editorOptions};
}
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();
}
}
public onSelectChange(updateDbRecord = true) {
if ( updateDbRecord ) {
this.dbRecord.Language = this.editorOptions.language;
this.editorService.triggerSave();
this.dirty = true;
}
this.editorOptions = {...this.editorOptions};
}
}

View File

@ -1,17 +1,15 @@
<div class="database-wrapper">
<ion-input
[readonly]="readonly"
[(ngModel)]="dbName"
(ionChange)="onCellValueChanged()"
style="font-size: 15pt;"
></ion-input>
<ion-toolbar *ngIf="!readonly">
<ion-buttons>
<ion-toolbar>
<ion-input
[readonly]="readonly"
[(ngModel)]="dbName"
(ionChange)="onCellValueChanged()"
style="font-size: 15pt;"
></ion-input>
<ion-buttons *ngIf="!readonly">
<ion-button (click)="onManageColumns()"><ion-icon name="build" color="primary"></ion-icon>&nbsp;Manage Columns</ion-button>
<ion-button (click)="onInsertRow()"><ion-icon name="add-circle" color="success"></ion-icon>&nbsp;Insert Row</ion-button>
<ion-button (click)="onRemoveRow()" [disabled]="lastClickRow < 0"><ion-icon name="remove-circle" color="danger"></ion-icon>&nbsp;Delete Row</ion-button>
<ion-button (click)="onSyncRecords()"><ion-icon name="save" [color]="dirty ? 'warning' : 'success'"></ion-icon>&nbsp;Sync Records</ion-button>
<ion-button (click)="onDropDatabase()"><ion-icon name="alert" color="danger"></ion-icon>&nbsp;Drop Database</ion-button>
</ion-buttons>
</ion-toolbar>
<div class="grid-wrapper">

View File

@ -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<HostRecord>();
@Output() requestParentSave = new EventEmitter<DatabaseComponent>();
@Output() requestParentDelete = new EventEmitter<DatabaseComponent>();
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<boolean> {
return this.dirty;
}
public needsSave(): boolean | Promise<boolean> {
return this.dirty;
}
public needsLoad(): boolean | Promise<boolean> {
return this.node && this.pendingSetup;
}
public writeChangesToNode(): void | Promise<void> {
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<any> {
return new Observable<any>(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<void> {
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);
// 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,
});
});
// 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,
});
});
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();
});
});
this.pendingSetup = false;
this.dirty = false;
}
public performDelete(): void | Promise<void> {
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,
});
});
}
getColumnLoadObservable(): Observable<any> {
return new Observable<any>(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();
});
public async performSave(): Promise<void> {
// 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,
});
});
}
setColumns(data): Observable<any> {
return new Observable<any>(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;
}
console.log({x});
return x;
// 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,
});
this.agGridElement.api.setColumnDefs(this.columnDefs);
sub.next();
sub.complete();
});
}
getInitObservable(): Observable<any> {
return new Observable<any>(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;
}
}

View File

@ -4,7 +4,6 @@
<input style="margin-top: 10px;" type="file" id="file" name="uploaded_file">
<input type="hidden" name="redirectTo" [value]="getReturnUrl()">
<ion-button (click)="onSubmitClick()" type="submit" fill="outline" class="ion-margin-start">Upload</ion-button>
<ion-button (click)="onDestroyClick()" type="submit" fill="outline" class="ion-margin-start" color="danger">Drop Files</ion-button>
</form>
</div>
<div class="existing-uploads">

View File

@ -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<HostRecord>();
@Output() requestParentSave = new EventEmitter<FilesComponent>();
@Output() requestParentDelete = new EventEmitter<FilesComponent>();
export class FilesComponent extends EditorNodeContract implements OnInit {
@Input() nodeId: string;
@ViewChild('uploadForm') uploadForm: ElementRef;
// @Input() readonly = false;
// @Input() hostRecord: HostRecord;
// @Output() hostRecordChange = new EventEmitter<HostRecord>();
// @Output() requestParentSave = new EventEmitter<FilesComponent>();
// @Output() requestParentDelete = new EventEmitter<FilesComponent>();
public fileRecords: Array<any> = [];
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<boolean> {
return this.dirty;
}
public writeChangesToNode(): void | Promise<void> {
this.node.Value.Mode = 'files';
}
public needsLoad(): boolean | Promise<boolean> {
return this.node && this.pendingSetup;
}
public async performLoad(): Promise<void> {
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<void> {
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<any> {
return new Observable<any>(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;
}
});
}
}

View File

@ -1,6 +1,9 @@
<ion-list>
<ion-item *ngFor="let menuItem of menuItems; let i = index" button (click)="onSelect(menuItems[i].value)">
<ion-icon slot="start" [name]="menuItems[i].icon"></ion-icon>
<ion-item *ngFor="let menuItem of menuItems; let i = index" button (click)="onSelect($event, menuItems[i].value)">
<i *ngIf="menuItems[i].icon" class="fa" slot="start" [ngClass]="menuItems[i].icon"></i>
<div *ngIf="menuItems[i].icons" slot="start">
<i *ngFor="let icon of menuItems[i].icons" class="fa" slot="start" [ngClass]="icon" style="margin-right: 5px;"></i>
</div>
<ion-label>{{ menuItems[i].name }}</ion-label>
</ion-item>
</ion-list>

View File

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

View File

@ -1,89 +0,0 @@
<ng-container>
<div
*ngIf="!page.isViewOnly() && ( record.type === 'paragraph'
|| record.type === 'header1'
|| record.type === 'header2'
|| record.type === 'header3'
|| record.type === 'header4'
|| record.type === 'block_code'
|| record.type === 'click_link' )"
class="host-host ion-padding"
contenteditable="true"
(keyup)="onKeyUp($event)"
(blur)="record.value=hostContainer.innerHTML"
(dblclick)="onHostDblClick()"
#hostContainer
[ngClass]="{'paragraph': record.type === 'paragraph', 'header1': record.type === 'header1', 'header2': record.type === 'header2', 'header3': record.type === 'header3', 'header4': record.type === 'header4', 'block_code': record.type === 'block_code', 'click_link': record.type === 'click_link'}"
[innerHTML]="record.value.replace('\n', '<br>')"
></div>
<div
*ngIf="page.isViewOnly() && ( record.type === 'paragraph'
|| record.type === 'header1'
|| record.type === 'header2'
|| record.type === 'header3'
|| record.type === 'header4'
|| record.type === 'block_code'
|| record.type === 'click_link' )"
(click)="onHostDblClick()"
class="host-host ion-padding"
#hostContainer
[ngClass]="{'paragraph': record.type === 'paragraph', 'header1': record.type === 'header1', 'header2': record.type === 'header2', 'header3': record.type === 'header3', 'header4': record.type === 'header4', 'block_code': record.type === 'block_code', 'click_link': record.type === 'click_link'}"
[innerHTML]="record.value.replace('\n', '<br>')"
></div>
<ul
*ngIf="record.type === 'ul' && !page.isViewOnly()"
class="host-host ion-padding"
>
<li
#liItems
contenteditable="true"
(keyup)="onUlKeyUp($event, i)"
(keydown)="onUlKeyDown($event, i)"
*ngFor="let line of listLines; let i = index"
[innerHTML]="listLines[i]"
></li>
</ul>
<ul
*ngIf="record.type === 'ul' && page.isViewOnly()"
class="host-host ion-padding"
>
<li
#liItems
*ngFor="let line of listLines; let i = index"
[innerHTML]="listLines[i]"
></li>
</ul>
<div *ngIf="record.type === 'page_sep'" class="hr-wrapper">
<hr>
</div>
<div
*ngIf="record.type === 'database_ref'"
class="db-wrapper"
>
<editor-database
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"
(requestParentDelete)="onRequestDelete($event)"
></editor-database>
</div>
<div class="code-wrapper" *ngIf="record.type === 'code_ref'">
<editor-code
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"
(requestParentDelete)="onRequestDelete($event)"
></editor-code>
</div>
<div class="files-wrapper" *ngIf="record.type === 'file_ref'">
<editor-files
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"
(requestParentDelete)="onRequestDelete($event)"
></editor-files>
</div>
</ng-container>

View File

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

View File

@ -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<HostRecord>();
@Output() newHostRequested = new EventEmitter<HostComponent>();
@Output() destroyHostRequested = new EventEmitter<HostComponent>();
@Output() saveHostRequested = new EventEmitter<HostComponent>();
@ViewChild('hostContainer') hostContainer: ElementRef;
@ViewChildren('liItems') liItems;
public listLines: Array<string> = [];
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);
}
}

View File

@ -1,50 +1,18 @@
<ion-list>
<ion-item button (click)="onSelect('paragraph')">
<ion-icon slot="start" name="menu"></ion-icon>
<ion-item button (click)="onSelect('paragraph')" class="node">
<i class="fa" slot="start" [ngClass]="typeIcons.node"></i>
<ion-label>Paragraph</ion-label>
</ion-item>
<ion-item button (click)="onSelect('header1')">
<ion-icon slot="start" name="alert"></ion-icon>
<ion-label>Heading 1</ion-label>
</ion-item>
<ion-item button (click)="onSelect('header2')">
<ion-icon slot="start" name="alert"></ion-icon>
<ion-label>Heading 2</ion-label>
</ion-item>
<ion-item button (click)="onSelect('header3')">
<ion-icon slot="start" name="alert"></ion-icon>
<ion-label>Heading 3</ion-label>
</ion-item>
<ion-item button (click)="onSelect('header4')">
<ion-icon slot="start" name="alert"></ion-icon>
<ion-label>Heading 4</ion-label>
</ion-item>
<ion-item button (click)="onSelect('ul')">
<ion-icon slot="start" name="list"></ion-icon>
<ion-label>Unordered List</ion-label>
</ion-item>
<ion-item button (click)="onSelect('block_code')">
<ion-icon slot="start" name="information"></ion-icon>
<ion-label>Monospace Block</ion-label>
</ion-item>
<ion-item button (click)="onSelect('click_link')">
<ion-icon slot="start" name="link"></ion-icon>
<ion-label>Hyperlink</ion-label>
</ion-item>
<ion-item button (click)="onSelect('database_ref')">
<ion-icon slot="start" name="analytics"></ion-icon>
<ion-item button (click)="onSelect('database_ref')" class="db">
<i class="fa" slot="start" [ngClass]="typeIcons.db"></i>
<ion-label>Database</ion-label>
</ion-item>
<ion-item button (click)="onSelect('code_ref')">
<ion-icon slot="start" name="code"></ion-icon>
<ion-item button (click)="onSelect('code_ref')" class="code">
<i class="fa" slot="start" [ngClass]="typeIcons.code"></i>
<ion-label>Code Editor</ion-label>
</ion-item>
<ion-item button (click)="onSelect('file_ref')">
<ion-icon slot="start" name="document"></ion-icon>
<ion-item button (click)="onSelect('file_ref')" class="files">
<i class="fa" slot="start" [ngClass]="typeIcons.files"></i>
<ion-label>Upload Files</ion-label>
</ion-item>
<ion-item button (click)="onSelect('page_sep')">
<ion-icon slot="start" name="remove"></ion-icon>
<ion-label>Horizontal Row</ion-label>
</ion-item>
</ion-list>

View File

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

View File

@ -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,
) { }

View File

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

View File

@ -0,0 +1,78 @@
<div class="container"
(focusin)="onFocusIn($event)"
(focusout)="onFocusIn($event)">
<div class="toolbar-base" *ngIf="isFocused">
<button class="toolbar-button" title="Bold" (click)="documentCommand('bold')">
<i class="icon fa fa-bold"></i>
</button>
<button class="toolbar-button" title="Italic" (click)="documentCommand('italic')">
<i class="icon fa fa-italic"></i>
</button>
<button class="toolbar-button" title="Underline" (click)="documentCommand('underline')">
<i class="icon fa fa-underline"></i>
</button>
<button class="toolbar-button" title="Strikethrough" (click)="documentCommand('strikeThrough')">
<i class="icon fa fa-strikethrough"></i>
</button>
<div class="toolbar-sep"></div>
<button class="toolbar-button" title="Align Left" (click)="documentCommand('justifyLeft')">
<i class="icon fa fa-align-left"></i>
</button>
<button class="toolbar-button" title="Align Center" (click)="documentCommand('justifyCenter')">
<i class="icon fa fa-align-center"></i>
</button>
<button class="toolbar-button" title="Align Right" (click)="documentCommand('justifyRight')">
<i class="icon fa fa-align-right"></i>
</button>
<div class="toolbar-sep"></div>
<button class="toolbar-button" title="Undo" (click)="documentCommand('undo')">
<i class="icon fa fa-undo"></i>
</button>
<button class="toolbar-button" title="Redo" (click)="documentCommand('redo')">
<i class="icon fa fa-redo"></i>
</button>
<div class="toolbar-sep"></div>
<button class="toolbar-button" title="Make text bigger" (click)="documentCommand('increaseFontSize')">
<i class="icon fa fa-font"></i>
<i class="icon fa fa-long-arrow-alt-up"></i>
</button>
<button class="toolbar-button" title="Make text smaller" (click)="documentCommand('decreaseFontSize')">
<i class="icon fa fa-font"></i>
<i class="icon fa fa-long-arrow-alt-down"></i>
</button>
<button class="toolbar-button" title="Make text superscript" (click)="documentCommand('superscript')">
<i class="icon fa fa-superscript"></i>
</button>
<button class="toolbar-button" title="Make text subscript" (click)="documentCommand('subscript')">
<i class="icon fa fa-subscript"></i>
</button>
<div class="toolbar-sep"></div>
<button class="toolbar-button" title="Insert unordered list" (click)="documentCommand('insertUnorderedList')">
<i class="icon fa fa-list-ul"></i>
</button>
<button class="toolbar-button" title="Insert ordered list" (click)="documentCommand('insertOrderedList')">
<i class="icon fa fa-list-ol"></i>
</button>
<button class="toolbar-button" title="Insert horizontal rule" (click)="documentCommand('insertHorizontalRule')">
</button>
</div>
<div
class="editable-base"
[ngClass]="isFocused ? 'focused' : ''"
contenteditable
appDomChange
[innerHTML]="initialValue"
#editable
(domChange)="onContentsChanged($event)"
></div>
</div>

View File

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

View File

@ -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<boolean> {
return this.dirtyOverride || this.contents !== this.initialValue;
}
public writeChangesToNode(): void | Promise<void> {
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');
}
}

View File

@ -36,6 +36,12 @@
color: var(--noded-background-code);
}
}
&.files {
.search-icon {
color: var(--noded-background-files);
}
}
}
.search-assoc {

View File

@ -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<SearchResult[]> = new BehaviorSubject<SearchResult[]>([]);
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,

View File

@ -0,0 +1,9 @@
import {NgModule} from '@angular/core';
import {DomChangeDirective} from './dom-change.directive';
@NgModule({
imports: [],
exports: [DomChangeDirective],
declarations: [DomChangeDirective],
})
export class DirectivesModule {}

View File

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

View File

@ -1,46 +1,55 @@
<ng-container>
<ion-header (keydown)="onEditorKeydown($event)">
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title #titleBar>
<div contenteditable="true"> {{ pageRecord.Name }} </div>
<ion-input
[(ngModel)]="editorService.mutablePageName"
placeholder="Click to edit page name..."
class="title-input"
></ion-input>
</ion-title>
<ion-buttons slot="end">
<button class="save-button" (click)="editorService.triggerSave()" title="Manually save this note">
<i *ngIf="!(editorService.isSaving || editorService.willSave)" class="fa fa-check-circle"></i>
{{ (editorService.isSaving || editorService.willSave) ? 'Saving...' : 'Saved!' }}
</button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content (keydown)="onEditorKeydown($event)">
<ion-content>
<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)"
class="host-container"
style="display: flex; margin-bottom: 20px;"
*ngFor="let node of editorService.immutableNodes"
>
<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 class="host-icons">
<i class="type-icon fa" [ngClass]="typeIcons[(node.isNorm() ? 'node' : node.type)] + ' ' + (node.isNorm() ? 'node' : node.type)"></i>
<button (click)="onOptionsClick($event, node)">
<i class="fa fa-ellipsis-v" title="Node options"></i>
</button>
</div>
<ng-container *ngIf="node.isNorm()">
<editor-norm style="flex: 1;" [nodeId]="node.UUID"></editor-norm>
</ng-container>
<ng-container *ngIf="node.type === 'database_ref'">
<editor-database style="flex: 1;" [nodeId]="node.UUID"></editor-database>
</ng-container>
<ng-container *ngIf="node.type === 'code_ref'">
<editor-code style="flex: 1;" [nodeId]="node.UUID"></editor-code>
</ng-container>
<ng-container *ngIf="node.type === 'file_ref'">
<editor-files style="flex: 1;" [nodeId]="node.UUID"></editor-files>
</ng-container>
</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>
<button class="host-add-button" (click)="onAddClick($event)">
<i class="fa fa-plus"></i> Add Node
</button>
</div>
</ng-container>
</ion-content>

View File

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

View File

@ -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<HostRecord> = [new HostRecord('Click to edit page...')];
public pageRecord: PageRecord = new PageRecord();
public pageId: string;
public visibleButtons: Array<number> = [];
// @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<HostRecord>) => {
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() ) {
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() ) {
async onOptionsClick(event: MouseEvent, node: HostRecord) {
if ( !this.editorService.canEdit() ) {
return;
}
const popover = await this.popover.create({
component: HostOptionsComponent,
event: $event,
event,
componentProps: {
editor: this,
index: i,
event: $event,
hostRecord: this.hostRecords[i],
index: this.editorService.immutableNodes.indexOf(node),
event,
hostRecord: node,
}
});
popover.onDidDismiss().then((result) => {
if ( result.data === 'delete_node' ) {
$event.record = this.hostRecords[i];
this.onDestroyHostRequested($event);
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();
}
onEditorKeydown($event) {
if ( $event.key === 's' && $event.ctrlKey ) {
$event.preventDefault();
this.onSaveClick();
async onAddClick(event: MouseEvent, position?: 'before' | 'after', positionNodeId?: string) {
if ( !this.editorService.canEdit() ) {
return;
}
const popover = await this.popover.create({
component: NodePickerComponent,
event,
});
popover.onDidDismiss().then(result => {
console.log('adding node', result.data);
if ( !result.data ) {
return;
}
this.editorService.addNode(result.data, position, positionNodeId);
});
// 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);
// }
//
// 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<HostRecord>) => {
// 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();
// }
// }
}

View File

@ -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<boolean> = new BehaviorSubject<boolean>(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<HostRecord[]> {
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<HostRecord> {
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<PageRecord> {
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<HostRecord[]> {
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,
});
});
}
}

View File

@ -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 => {

View File

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

View File

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