commit
8ca9b736eb
@ -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;
|
||||
|
||||
public dirty = false;
|
||||
public pendingSetup = true;
|
||||
protected dbRecord: any = {};
|
||||
|
||||
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 editorOptions = {
|
||||
language: 'javascript',
|
||||
uri: v4(),
|
||||
readOnly: this.readonly,
|
||||
};
|
||||
public editorValue = '';
|
||||
|
||||
constructor(
|
||||
protected api: ApiService,
|
||||
protected loader: LoadingController,
|
||||
protected alerts: AlertController,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.loader.create({message: 'Loading code...'}).then(loader => {
|
||||
loader.present().then(() => {
|
||||
this.getInitObservable().subscribe(() => {
|
||||
this.editorOptions.language = this.dbRecord.Language;
|
||||
this.editorOptions.readOnly = this.readonly;
|
||||
this.onSelectChange(false);
|
||||
loader.dismiss();
|
||||
});
|
||||
export class CodeComponent extends EditorNodeContract implements OnInit {
|
||||
@Input() nodeId: string;
|
||||
public dirty = false;
|
||||
protected dbRecord: any = {};
|
||||
protected codeRefId!: string;
|
||||
|
||||
public editorOptions = {
|
||||
language: 'javascript',
|
||||
uri: v4(),
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
public editorValue = '';
|
||||
public get readonly() {
|
||||
return !this.node || !this.editorService.canEdit();
|
||||
}
|
||||
|
||||
public languageOptions: Array<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(
|
||||
public readonly editorService: EditorService,
|
||||
public readonly api: ApiService,
|
||||
) { super(); }
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getInitObservable(): Observable<any> {
|
||||
return new Observable<any>(sub => {
|
||||
if ( this.hostRecord && this.pendingSetup ) {
|
||||
if ( !this.hostRecord.Value ) {
|
||||
this.hostRecord.Value = {};
|
||||
}
|
||||
|
||||
public performSave(): void | Promise<void> {
|
||||
if ( !this.editorService.canEdit() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( !this.hostRecord.Value.Value && !this.readonly ) {
|
||||
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => {
|
||||
this.dbRecord = res.data;
|
||||
this.hostRecord.Value.Mode = 'code';
|
||||
this.hostRecord.Value.Value = res.data.UUID;
|
||||
this.hostRecord.value = res.data.UUID;
|
||||
this.hostRecordChange.emit(this.hostRecord);
|
||||
this.pendingSetup = false;
|
||||
sub.next(true);
|
||||
sub.complete();
|
||||
});
|
||||
} else {
|
||||
this.api.get(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}`).subscribe(res => {
|
||||
this.dbRecord = res.data;
|
||||
this.editorValue = this.dbRecord.code;
|
||||
this.editorOptions.language = this.dbRecord.Language;
|
||||
this.pendingSetup = false;
|
||||
sub.next(true);
|
||||
sub.complete();
|
||||
return new Promise((res, rej) => {
|
||||
this.dbRecord.code = this.editorValue;
|
||||
this.dbRecord.Language = this.editorOptions.language;
|
||||
|
||||
this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}`, this.dbRecord)
|
||||
.subscribe({
|
||||
next: result => {
|
||||
this.dbRecord = result.data;
|
||||
this.editorOptions.language = this.dbRecord.Language;
|
||||
this.editorValue = this.dbRecord.code;
|
||||
this.dirty = false;
|
||||
res();
|
||||
},
|
||||
error: rej,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public performDelete(): void | Promise<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,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.pendingSetup = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSaveClick() {
|
||||
if ( this.readonly ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dbRecord.code = this.editorValue;
|
||||
this.dbRecord.Language = this.editorOptions.language;
|
||||
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}`, this.dbRecord)
|
||||
.subscribe(res => {
|
||||
this.dbRecord = res.data;
|
||||
this.editorOptions.language = this.dbRecord.Language;
|
||||
this.editorValue = this.dbRecord.code;
|
||||
this.dirty = false;
|
||||
});
|
||||
}
|
||||
|
||||
async onDropClick() {
|
||||
if ( this.readonly ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = await this.alerts.create({
|
||||
header: 'Are you sure?',
|
||||
message: `You are about to delete this code. This action cannot be undone.`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Keep It',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete It',
|
||||
handler: async () => {
|
||||
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/delete/${this.hostRecord.Value.Value}`)
|
||||
.subscribe(res => {
|
||||
this.requestParentDelete.emit(this);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
public onEditorModelChange($event) {
|
||||
if ( this.editorValue !== this.dbRecord.code ) {
|
||||
this.dirty = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSelectChange(updateDbRecord = true) {
|
||||
if ( updateDbRecord ) {
|
||||
this.dbRecord.Language = this.editorOptions.language;
|
||||
ngOnInit() {
|
||||
this.editorService.registerNodeEditor(this.nodeId, this).then(() => {
|
||||
this.editorOptions.readOnly = !this.editorService.canEdit();
|
||||
});
|
||||
}
|
||||
|
||||
public onEditorModelChange($event) {
|
||||
if ( this.editorValue !== this.dbRecord.code ) {
|
||||
this.dirty = true;
|
||||
this.editorService.triggerSave();
|
||||
}
|
||||
}
|
||||
|
||||
this.editorOptions = {...this.editorOptions};
|
||||
}
|
||||
public onSelectChange(updateDbRecord = true) {
|
||||
if ( updateDbRecord ) {
|
||||
this.dbRecord.Language = this.editorOptions.language;
|
||||
this.editorService.triggerSave();
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
this.editorOptions = {...this.editorOptions};
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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> {}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {DomChangeDirective} from './dom-change.directive';
|
||||
|
||||
@NgModule({
|
||||
imports: [],
|
||||
exports: [DomChangeDirective],
|
||||
declarations: [DomChangeDirective],
|
||||
})
|
||||
export class DirectivesModule {}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -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',
|
||||
};
|
Loading…
Reference in new issue