You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
frontend/src/app/service/editor.service.ts

334 lines
10 KiB

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