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 {v4} from 'uuid';
|
||||||
import HostRecord from '../../../structures/HostRecord';
|
|
||||||
import {Observable} from 'rxjs';
|
|
||||||
import {ApiService} from '../../../service/api.service';
|
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({
|
@Component({
|
||||||
selector: 'editor-code',
|
selector: 'editor-code',
|
||||||
templateUrl: './code.component.html',
|
templateUrl: './code.component.html',
|
||||||
styleUrls: ['./code.component.scss'],
|
styleUrls: ['./code.component.scss'],
|
||||||
})
|
})
|
||||||
export class CodeComponent implements OnInit {
|
export class CodeComponent extends EditorNodeContract implements OnInit {
|
||||||
@Input() readonly = false;
|
@Input() nodeId: string;
|
||||||
@Input() hostRecord: HostRecord;
|
public dirty = false;
|
||||||
@Output() hostRecordChange = new EventEmitter<HostRecord>();
|
protected dbRecord: any = {};
|
||||||
@Output() requestParentSave = new EventEmitter<CodeComponent>();
|
protected codeRefId!: string;
|
||||||
@Output() requestParentDelete = new EventEmitter<CodeComponent>();
|
|
||||||
@ViewChild('theEditor') theEditor;
|
public editorOptions = {
|
||||||
|
language: 'javascript',
|
||||||
public dirty = false;
|
uri: v4(),
|
||||||
public pendingSetup = true;
|
readOnly: false,
|
||||||
protected dbRecord: any = {};
|
};
|
||||||
|
|
||||||
public languageOptions: Array<string> = [
|
public editorValue = '';
|
||||||
'ABAP',
|
public get readonly() {
|
||||||
'AES',
|
return !this.node || !this.editorService.canEdit();
|
||||||
'Apex',
|
}
|
||||||
'AZCLI',
|
|
||||||
'Bat',
|
public languageOptions: Array<string> = [
|
||||||
'C',
|
'ABAP',
|
||||||
'Cameligo',
|
'AES',
|
||||||
'Clojure',
|
'Apex',
|
||||||
'CoffeeScript',
|
'AZCLI',
|
||||||
'Cpp',
|
'Bat',
|
||||||
'Csharp',
|
'C',
|
||||||
'CSP',
|
'Cameligo',
|
||||||
'CSS',
|
'Clojure',
|
||||||
'Dockerfile',
|
'CoffeeScript',
|
||||||
'Fsharp',
|
'Cpp',
|
||||||
'Go',
|
'Csharp',
|
||||||
'GraphQL',
|
'CSP',
|
||||||
'Handlebars',
|
'CSS',
|
||||||
'HTML',
|
'Dockerfile',
|
||||||
'INI',
|
'Fsharp',
|
||||||
'Java',
|
'Go',
|
||||||
'JavaScript',
|
'GraphQL',
|
||||||
'JSON',
|
'Handlebars',
|
||||||
'Kotlin',
|
'HTML',
|
||||||
'LeSS',
|
'INI',
|
||||||
'Lua',
|
'Java',
|
||||||
'Markdown',
|
'JavaScript',
|
||||||
'MiPS',
|
'JSON',
|
||||||
'MSDAX',
|
'Kotlin',
|
||||||
'MySQL',
|
'LeSS',
|
||||||
'Objective-C',
|
'Lua',
|
||||||
'Pascal',
|
'Markdown',
|
||||||
'Pascaligo',
|
'MiPS',
|
||||||
'Perl',
|
'MSDAX',
|
||||||
'pgSQL',
|
'MySQL',
|
||||||
'PHP',
|
'Objective-C',
|
||||||
'Plaintext',
|
'Pascal',
|
||||||
'Postiats',
|
'Pascaligo',
|
||||||
'PowerQuery',
|
'Perl',
|
||||||
'PowerShell',
|
'pgSQL',
|
||||||
'Pug',
|
'PHP',
|
||||||
'Python',
|
'Plaintext',
|
||||||
'R',
|
'Postiats',
|
||||||
'Razor',
|
'PowerQuery',
|
||||||
'Redis',
|
'PowerShell',
|
||||||
'RedShift',
|
'Pug',
|
||||||
'RestructuredText',
|
'Python',
|
||||||
'Ruby',
|
'R',
|
||||||
'Rust',
|
'Razor',
|
||||||
'SB',
|
'Redis',
|
||||||
'Scheme',
|
'RedShift',
|
||||||
'SCSS',
|
'RestructuredText',
|
||||||
'Shell',
|
'Ruby',
|
||||||
'SOL',
|
'Rust',
|
||||||
'SQL',
|
'SB',
|
||||||
'St',
|
'Scheme',
|
||||||
'Swift',
|
'SCSS',
|
||||||
'TCL',
|
'Shell',
|
||||||
'Twig',
|
'SOL',
|
||||||
'TypeScript',
|
'SQL',
|
||||||
'VB',
|
'St',
|
||||||
'XML',
|
'Swift',
|
||||||
'YAML',
|
'TCL',
|
||||||
];
|
'Twig',
|
||||||
|
'TypeScript',
|
||||||
public editorOptions = {
|
'VB',
|
||||||
language: 'javascript',
|
'XML',
|
||||||
uri: v4(),
|
'YAML',
|
||||||
readOnly: this.readonly,
|
];
|
||||||
};
|
protected hadLoad = false;
|
||||||
public editorValue = '';
|
|
||||||
|
constructor(
|
||||||
constructor(
|
public readonly editorService: EditorService,
|
||||||
protected api: ApiService,
|
public readonly api: ApiService,
|
||||||
protected loader: LoadingController,
|
) { super(); }
|
||||||
protected alerts: AlertController,
|
|
||||||
) { }
|
public isDirty(): boolean | Promise<boolean> {
|
||||||
|
return this.dirty;
|
||||||
ngOnInit() {
|
}
|
||||||
this.loader.create({message: 'Loading code...'}).then(loader => {
|
|
||||||
loader.present().then(() => {
|
public needsSave(): boolean | Promise<boolean> {
|
||||||
this.getInitObservable().subscribe(() => {
|
return this.dirty;
|
||||||
this.editorOptions.language = this.dbRecord.Language;
|
}
|
||||||
this.editorOptions.readOnly = this.readonly;
|
|
||||||
this.onSelectChange(false);
|
public writeChangesToNode(): void | Promise<void> {
|
||||||
loader.dismiss();
|
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> {
|
||||||
getInitObservable(): Observable<any> {
|
if ( !this.editorService.canEdit() ) {
|
||||||
return new Observable<any>(sub => {
|
return;
|
||||||
if ( this.hostRecord && this.pendingSetup ) {
|
|
||||||
if ( !this.hostRecord.Value ) {
|
|
||||||
this.hostRecord.Value = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( !this.hostRecord.Value.Value && !this.readonly ) {
|
return new Promise((res, rej) => {
|
||||||
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => {
|
this.dbRecord.code = this.editorValue;
|
||||||
this.dbRecord = res.data;
|
this.dbRecord.Language = this.editorOptions.language;
|
||||||
this.hostRecord.Value.Mode = 'code';
|
|
||||||
this.hostRecord.Value.Value = res.data.UUID;
|
this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}`, this.dbRecord)
|
||||||
this.hostRecord.value = res.data.UUID;
|
.subscribe({
|
||||||
this.hostRecordChange.emit(this.hostRecord);
|
next: result => {
|
||||||
this.pendingSetup = false;
|
this.dbRecord = result.data;
|
||||||
sub.next(true);
|
this.editorOptions.language = this.dbRecord.Language;
|
||||||
sub.complete();
|
this.editorValue = this.dbRecord.code;
|
||||||
});
|
this.dirty = false;
|
||||||
} else {
|
res();
|
||||||
this.api.get(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}`).subscribe(res => {
|
},
|
||||||
this.dbRecord = res.data;
|
error: rej,
|
||||||
this.editorValue = this.dbRecord.code;
|
});
|
||||||
this.editorOptions.language = this.dbRecord.Language;
|
});
|
||||||
this.pendingSetup = false;
|
}
|
||||||
sub.next(true);
|
|
||||||
sub.complete();
|
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) {
|
ngOnInit() {
|
||||||
if ( updateDbRecord ) {
|
this.editorService.registerNodeEditor(this.nodeId, this).then(() => {
|
||||||
this.dbRecord.Language = this.editorOptions.language;
|
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-list>
|
||||||
<ion-item *ngFor="let menuItem of menuItems; let i = index" button (click)="onSelect(menuItems[i].value)">
|
<ion-item *ngFor="let menuItem of menuItems; let i = index" button (click)="onSelect($event, menuItems[i].value)">
|
||||||
<ion-icon slot="start" [name]="menuItems[i].icon"></ion-icon>
|
<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-label>{{ menuItems[i].name }}</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-list>
|
</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-list>
|
||||||
<ion-item button (click)="onSelect('paragraph')">
|
<ion-item button (click)="onSelect('paragraph')" class="node">
|
||||||
<ion-icon slot="start" name="menu"></ion-icon>
|
<i class="fa" slot="start" [ngClass]="typeIcons.node"></i>
|
||||||
<ion-label>Paragraph</ion-label>
|
<ion-label>Paragraph</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item button (click)="onSelect('header1')">
|
<ion-item button (click)="onSelect('database_ref')" class="db">
|
||||||
<ion-icon slot="start" name="alert"></ion-icon>
|
<i class="fa" slot="start" [ngClass]="typeIcons.db"></i>
|
||||||
<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-label>Database</ion-label>
|
<ion-label>Database</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item button (click)="onSelect('code_ref')">
|
<ion-item button (click)="onSelect('code_ref')" class="code">
|
||||||
<ion-icon slot="start" name="code"></ion-icon>
|
<i class="fa" slot="start" [ngClass]="typeIcons.code"></i>
|
||||||
<ion-label>Code Editor</ion-label>
|
<ion-label>Code Editor</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item button (click)="onSelect('file_ref')">
|
<ion-item button (click)="onSelect('file_ref')" class="files">
|
||||||
<ion-icon slot="start" name="document"></ion-icon>
|
<i class="fa" slot="start" [ngClass]="typeIcons.files"></i>
|
||||||
<ion-label>Upload Files</ion-label>
|
<ion-label>Upload Files</ion-label>
|
||||||
</ion-item>
|
</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>
|
</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