/** * @author Martin Karkowski * @email m.karkowski@zema.de * @create date 2020-04-09 11:49:53 * @modify date 2020-07-22 21:58:43 * @desc [description] */ import { OnInit, Input, ViewChild, OnDestroy, Type, Component } from '@angular/core'; import { rsetattr, rgetattr, deepClone } from '../../@zema/ZISS-TypeScript-Library/src/Object-Methods'; import { ITemplate, IMustacheTemplate } from './interfaces/ITemplate'; import { IEditPage } from './edit-pages/edit-pages.interface'; import { initEditor } from './user-interface/editor'; import { defaultToolbar } from './defaults/default.toolbar'; import { IContextConfig, rigthClickActions, generateDefaulContextMenu } from './defaults/default.context-menu'; import { generateGraphOptions } from './defaults/default.graph-options'; import { generateAddFunction } from './defaults/default.add-edge-method'; import { genDefaultDict } from './defaults/default.edit-dict'; import { stringify, parse, parseWithFunctions } from '../../@zema/ZISS-TypeScript-Library/src/JSON'; import { enableClusterPreview } from './addons/cluster.preview'; import 'style-loader!angular2-toaster/toaster.css'; import { ZemaServiceProvider } from '../../@zema/zema-service-provider.service'; import { writeToClipboard } from '../../@zema/ZISS-Browser-Library/src/clipboard'; import { getSubElements, adaptPositions, adaptIDS } from './helpers/data.handlers'; import { IUndoRedoGraph } from './interfaces/IGraph'; import { IBaseNodeOptions } from '../../@zema/ZISS-Network/type/IBaseNodeOptions'; import { IBaseEdgeOptions } from '../../@zema/ZISS-Network/type/IBaseEdgeOptions'; import { IGraphToolComponent, ICallbackData } from './interfaces/IGraphTool';import { ILayoutOptions, IBasicLayoutComponent, IRigthClickActions, ISelectionTemplate, IToolbarConfig, IPossiblePanels, ISelectionConfig } from '../gui-components-basic-layout/types/interfaces'; import { defaultHotkeys } from './defaults/default.hotkeys'; import { BasicLayoutComponent } from '../gui-components-basic-layout/src/layout.component'; import { TemplateEditorComponent } from './edit-pages/template.edit-page'; import { IVisjsOptions } from './interfaces/IVisjsOptions'; import { waitFor, sleep } from '../../@zema/ZISS-TypeScript-Library/src/Async-Helpers'; import { NbThemeService, NbGlobalPhysicalPosition } from '@nebular/theme'; @Component({ template: `` }) export class BaseGraphEditor> implements IGraphToolComponent,OnInit, OnDestroy { public parseFunctions = true; public layoutOptions: ILayoutOptions | IMustacheTemplate, D>; public options: { addEdgeCallback?: (edgeData: E, callback: (edgeData: E) => void) => void; editOnSelect?: boolean; editOnChange?: boolean; parseFunctions?: boolean; enableContextMenu?: boolean; enableEditing?: boolean; hidePanelOnDeselect?: boolean; hideToolbar?: boolean; hideRightPanel?: boolean; } = { enableEditing: true, enableContextMenu: true, } /** * Element for Providing the VIS-JS Options */ @Input() public set visjsOptions(value: IVisjsOptions) { this._visjsOptions = value; if (this.network) { this.network.network.setOptions(value); } } public get visjsOptions(): IVisjsOptions { return this._visjsOptions; } private _visjsOptions: IVisjsOptions = generateGraphOptions(); @Input() public set nodes(nodes: Array) { this._nodes = nodes; // If a Network is available, use this one // to Render Elements. if (this.network) { this.network.clearNodes(); this.network.addNode(nodes); } } public get nodes() { // If a Network is available, use this one // to return the Elements. if (this.network) { return this.network.nodes; } // Otherwise return an empty Array return []; } private _nodes = new Array(); @Input() public set edges(edges: Array) { this._edges = edges; // If a Network is available, use this one // to Render Elements. if (this.network) { this.network.clearEdges(); this.network.addEdge(edges); } } public get edges() { if (this.network) { return this.network.edges; } return []; } private _edges = new Array(); @Input() public set editPanelDict(value: { nodes: { [index: string]: Type> }, edges: { [index: string]: Type> } }) { if (!value.nodes.default || !value.edges.default) { throw TypeError('A Default Element must be specified'); } this._editPanelDict = value; } public get editPanelDict() { return this._editPanelDict; } private _editPanelDict = genDefaultDict() @Input() public set toolbar(config: IToolbarConfig>) { this._toolbar = config; } public get toolbar() { return this._toolbar; } protected _toolbar = defaultToolbar(generateGraphOptions(),{ useVersionControl: true }); /** * Container with Actions * */ public rightClickActions: rigthClickActions = []; public contextMenuGenerator: IContextConfig = generateDefaulContextMenu(); public network: IUndoRedoGraph; public templates: ISelectionConfig | IMustacheTemplate>; public readonly divID = 'editor'; /** Element storing the current Mouse-Position */ public get mousePos() { if (this.layout && this.layout.currentMousePosition) { return { x: this.layout.currentMousePosition.offsetX, y: this.layout.currentMousePosition.offsetY } } return { x: 0, y: 0 }; } /** * Element containing the Template, which will be added. */ public get template(): ITemplate | IMustacheTemplate | null { if (this.layoutOptions.selection){ return this.layout.selection.getSelectetTemplate() } return null; } /** * Creates an instance of GraphToolComponent. * @param contextMenuService The Context Menu Service */ constructor( public zemaService: ZemaServiceProvider, protected themeService: NbThemeService ) { const _this = this; this.themeService.getJsTheme().subscribe(() => { _this.ngOnDestroy(); _this.initEditor().catch(err => _this.zemaService.logger.error(err)); }); } public adaptedData(event, data: IPossiblePanels): D { throw Error('Abstract Class, not implemented'); } @ViewChild(BasicLayoutComponent, {static: true}) public layout: IBasicLayoutComponent | IMustacheTemplate, D>; public loadJSON(data: string, overwrite = true) { try { /** Read In the JSON File */ const content = parse(data, this.parseFunctions); /** Load the Data itself */ this.network.loadData(content, overwrite) } catch (e) { this.zemaService.showToast('danger', 'Failed Loading', e, 0); this.layout.openDialogWithText({ text: e.toString(), title: 'Failed loading Data to Graph', closeOnBackdropClick: true, dynamicSize: true, buttons: [ { callback: close => close(), label: 'OK', status: 'danger' } ] }) this.zemaService.logger.error(e, 'Failed loading Data to Graph'); } } /** * A Function to open Up an Edit-Window, * Rendering the content of the Component. */ public openEditInterface>( /*** The Angular Component */ component: Type, /** The Stettings, of the Component */ settings: { inputTemplate?: ITemplate, [index: string]: any, }, title: string, /** callback, if the Sucess-Button is pressed */ sucessCallback: (data: { template : ITemplate, callback?: (template: ITemplate) => ITemplate }) => void, mode: 'sidebar' | 'popup' = 'popup') { const _this = this; // Disable the Hotkeys. this.disableHotkeys(); this.zemaService.logger.info('Open edit Window',mode) switch(mode){ case 'sidebar': this.layout.openDynamicPanel({ title, component: { component: component, inputs: Object.assign(settings,{ inputTemplate: settings.inputTemplate, graph: this.network }) }, buttons: [ { label: 'Save', status: 'success', callback(instance, close, changePanelVisbility) { if (instance.isValid()) { _this.enableHotkeys(); sucessCallback(instance.getAdapted()); if (!_this.options.editOnSelect){ close(); changePanelVisbility(false); } } else { _this.layout.panels.right.showMessage({ body: 'Error in Data. Data canot be stored', hideOnClick: true, buttons: 'close' }) } } }, { label: 'Abort', status: 'danger', callback(instance, close, changePanelVisbility) { _this.enableHotkeys(); close(); changePanelVisbility(false); } } ], showOnCreate: true, panel: 'right', append: false }) break; case 'popup': this.layout.openDialogComponent({ title, component: { component, inputs: Object.assign(settings,{ inputTemplate: settings.inputTemplate, graph: this.network }) }, buttons: [ { label: 'Save', status: 'success', callback(instance, close) { if (instance.isValid()) { _this.enableHotkeys(); sucessCallback(instance.getAdapted()); close(); } } }, { label: 'Abort', status: 'danger', callback(instance, close) { _this.enableHotkeys(); close(); } } ], closeOnBackdropClick: false, closeOnEsc: false }); break; } } public addNode(pos: { x: number, y: number }) { const _this = this; switch (this.template.type) { case 'elements': if (this.template.nodes && this.template.nodes.length > 0) { this.template.nodes[0].x = pos.x; this.template.nodes[0].y = pos.y; let componentSelector = 'default'; if (this.template.nodes.length > 0 && this.template.nodes[0].editorComponentSelector) { componentSelector = this.template.nodes[0].editorComponentSelector; } this.openEditInterface( this.editPanelDict.nodes[componentSelector], { inputTemplate: deepClone(this.template as ITemplate), }, 'Add Node' , (data) => { let adapted = adaptIDS(data.template); if (typeof data.callback === 'function') { adapted = data.callback(adapted); } _this.network.addNode(adapted.nodes); _this.network.addEdge(adapted.edges); }, 'popup' ); } return true; default: this.enableHotkeys(); } return false } /** * Function to Update the Data of a Node. * @param selection The Selected Node. */ public updateNode(selection: Array) { const _self = this; /** Extract the Component, which should be used in the Prompt */ let componentSelector = 'default'; if (selection.length > 0 && selection[0].editorComponentSelector) { componentSelector = selection[0].editorComponentSelector; } const comp = this.editPanelDict.nodes[componentSelector]; if (comp) { /** Open the Window, with the Edit-Prompt */ this.openEditInterface(this.editPanelDict.nodes[componentSelector], { inputTemplate: { nodes: selection, edges: [], type: 'elements' }, }, 'Edit Node', (data) => { _self.network.updateNode(data.template.nodes); }); } else { this.zemaService.logger.warn('Editor is Trying to open an Unkown Edit-Component'); } } /** * Function, which is used to Update an Edge * @param edge The Corresponding Edge, which will be updated. */ public updateEdges(edge: E) { const _self = this; /** Extract the Component, which should be used in the Prompt */ let componentSelector = 'default'; if (edge.editorComponentSelector) { componentSelector = edge.editorComponentSelector; } const comp = this.editPanelDict.edges[componentSelector]; if (comp) { /** Open the Window, with the Edit-Prompt */ this.openEditInterface(comp, { inputTemplate: { nodes: [], edges: [edge], type: 'elements' } }, 'Edit Edge', (data) => { _self.network.updateEdge(data.template.edges); }); } else { throw TypeError('The Element trys to open an Unkown Edit-Component'); } } private _destroyNetwork: () => void; ngOnDestroy(): void { if (typeof this._destroyNetwork === 'function') this._destroyNetwork(); } protected _updateLayoutOptions(options: ILayoutOptions | IMustacheTemplate, D>){ return options; } public async initEditor() { const _this = this; let colors: any = null; const subcription = this.themeService.getJsTheme().subscribe((value) => { colors = value.variables; }); await waitFor(() => (colors !== undefined && colors !== null), { additionalDelay: 100 }); subcription.unsubscribe(); const layoutOptions: ILayoutOptions | IMustacheTemplate, D> = { title: 'Editor', panels: [ { type: 'right', id: 'properties', hidden: true, resizable: true, minSize: 300, toggle: ! this.options.hideRightPanel, style: "background-color: "+ colors.bg2 } ], adaptData(event, panels){ return _this.adaptedData(event, panels) }, hotkeys: defaultHotkeys(), onCopy(data){ try { _this.copySelectionToClipboard(); data.zemaService.showToast('success', 'Clipboard', 'Copied Content to Clipboard'); } catch (e) { data.zemaService.showToast('warning', 'Clipboard', 'Failed Copying To Clipboard'); } }, onPaste(text, data){ try { _this.paste(parseWithFunctions(text),data.network.network.DOMtoCanvas(data.component.mousePos), false); data.zemaService.showToast('success', 'Clipboard', 'Copied Content to Clipboard'); } catch (e) { data.zemaService.showToast('warning', 'Clipboard', 'Failed Copying To Clipboard'); } }, showToggleElements: true } if (!this.options.hideToolbar) { layoutOptions.panels.push({ type: 'top', id: 'toolbar', toggle: false, }); layoutOptions.toolbar = { panel: 'top', config: this.toolbar } } if (this.templates){ layoutOptions.selection = { panel: 'left', id: 'selection', templates: this.templates, preview: { id: 'preview', type: 'preview', resizable: true } }; layoutOptions.panels.push({ type: 'left', id: 'left', hidden: false, resizable: true, minSize: 200, maxSize: 500, overflow: 'hidden', toggle: false, }); } this.layoutOptions = this._updateLayoutOptions(layoutOptions); // Wait until the Layout has been initialized await waitFor(async function(){ while (!_this.layout) { sleep(10); } return true; }) // Wait until the Editor is Ready and then Create the 3D Renderer await this.layout.ready.waitFor((value) => value === true); /** Generate the Default Callback for adding a Node */ rsetattr( this._visjsOptions, 'manipulation.addEdge', generateAddFunction(_this, (data, callback) => { /** Test if an Edge-Template exists */ if (_this.template.type === 'elements' && _this.template.edges.length === 1 && _this.template.nodes.length === 0) { const edge = Object.assign( _this.template.edges[0], data ); if (typeof _this.options.addEdgeCallback === 'function') { _this.options.addEdgeCallback(edge, callback); } else { callback(edge); } } } ) ); if (!rgetattr(this._visjsOptions, 'manipulation.enabled', false)) { rsetattr(this._visjsOptions, 'manipulation.enabled', false); } const editor = initEditor>({ component: this, element: this.layout.panels.main, networkOptions: this.visjsOptions, renderMinimap: false }); // Store the Network this.network = editor.network; this._destroyNetwork = editor.destroy; // Adapt the Message Function this.network.showMessage = (type, title, body, duration = 5000) => { _this.zemaService.showToast(type, title, body, duration); } const editItemIfPossible = (params) => { if (params.nodes.length === 1) { // A Node was Selected. Figure out, whether it is a cluster or not. const nodeID = params.nodes[0]; if (_this.network.network.isCluster(nodeID)) { // Uncluster the Nodes _this.network.network.openCluster(nodeID); } else { // Open up the Settings Tab const selection = getSubElements(this.network, false); _this.updateNode(selection); } } else if (params.edges.length === 1) { // A Node was Selected. Figure out, whether it is a cluster or not. const edges = _this.network.data.edges.get(params.edges[0]); _this.updateEdges(edges); } } // Make the Default behaviour adding new Nodes this.network.on('doubleClick', (params) => { if (this.options.enableEditing){ // Test if no Element was Selected if (params.nodes.length === 0 && params.edges.length === 0) { // If so, just add the new Element _this.addNode(params.pointer.canvas); } else { editItemIfPossible(params); } } }); this.network.on('oncopy', event => { try { _this.copySelectionToClipboard(); _this.zemaService.showToast('success', 'Clipboard', 'Copied Content to Clipboard'); } catch (e) { _this.zemaService.showToast('warning', 'Clipboard', 'Failed Copying To Clipboard'); } }); this.network.on('onpaste', async event => { console.log(event) try { const data = await _this.readDataFromClipboard() _this.paste(parseWithFunctions(data), _this.network.network.DOMtoCanvas(_this.mousePos), false); _this.zemaService.showToast('success', 'Clipboard', 'Copied Content to Clipboard'); } catch (e) { _this.zemaService.showToast('warning', 'Clipboard', 'Failed Copying To Clipboard'); } }); let _mousePosOnContextMenu = this.mousePos; let rightClickActions: IRigthClickActions> = []; const _contextHandler = (params) => { let nodeID = null; if (params.nodes.length === 1) { nodeID = _this.network.network.getNodeAt(params.pointer.DOM); if (!nodeID) nodeID = params.nodes[0]; } let edgeID = null; if (params.edges.length === 1) { edgeID = params.edges[0]; } // If nothing is selected, try to select the element, // which is underneath the Pointer if (params.nodes.length === 0) { nodeID = _this.network.network.getNodeAt(params.pointer.DOM); // Test if a Node is underneath the Pointer if (nodeID) { // Make shure the Element is selected _this.network.network.selectNodes([nodeID]); } } // If nothing is selected, try to select the element, // which is underneath the Pointer if (params.edges.length === 0) { edgeID = _this.network.network.getEdgeAt(params.pointer.DOM); // Test if a Node is underneath the Pointer if (edgeID) { // Make shure the Element is selected _this.network.network.selectEdges([edgeID]); } } // Test whether multiple Nodes or just a single Node is selected. if (nodeID) { // A single Node is selected rightClickActions = _this.contextMenuGenerator.node(_this, params.pointer.DOM, nodeID); } else if (params.nodes.length > 1) { // The Default Right-Clickmenu must be selected rightClickActions = _this.contextMenuGenerator.default(_this, params.pointer.DOM, params.nodes, params.edges); } else if (edgeID) { // Only 1 Edge is selected rightClickActions = _this.contextMenuGenerator.edge(_this, params.pointer.DOM, edgeID); } else { rightClickActions = _this.contextMenuGenerator.background(_this, params.pointer.DOM); } } this.network.on('oncontext', (params) => { if (_this.options.enableContextMenu){ // Make shure the Context-Menu is only Opened, if // The event isnt prevented if (params.event.prevent) { return; } // Call creating the Menu Entries. _contextHandler(params) // Decide, whether an Element is selected or not params.event.preventDefault(); // Store the current Position _mousePosOnContextMenu = _this.mousePos; // Check after a View seconds whether the Mouse has been move // setTimeout(_contextHandler, 200, params); setTimeout(() => { if (Math.abs(_this.mousePos.x - _mousePosOnContextMenu.x) < 5 && Math.abs(_this.mousePos.y - _mousePosOnContextMenu.y) < 5) { if (rightClickActions.length > 0) { _this.layout.openContextMenu(params.event, rightClickActions); } else { _this.network.network.unselectAll(); } } else { rightClickActions = []; } }, 200) } }); // Make shure the Sidepanel is working correctly this.network.on('select', (params) => { if (_this.options.enableEditing){ if (_this.options.editOnSelect && _this.options.hidePanelOnDeselect && params.nodes.length === 0 && params.edges.length === 0){ _this.layout.panels.right.hide(); } else if (_this.options.editOnSelect){ editItemIfPossible(params); } } }); enableClusterPreview(this); this.network.addNode(this._nodes); this.network.addEdge(this._edges); this.layoutOptions.onResized = () => { editor.resize(); } editor.resize(); } /** * Function to initalize the Editor */ public ngOnInit() { // const _this = this; // this.initEditor().catch(err => _this.zemaService.logger.error(err, 'Init of Editor Failed.')); } public enableHotkeys() { this.layout.hotkeysEnabled = true; } public disableHotkeys() { this.layout.hotkeysEnabled = false; // this.network.network.disableEditMode(); } /** * Create a copy of the current Selection. * * @returns A Template containing the Selected Nodes and Edges. * @memberof GraphToolComponent */ public createTemplateOfSelectedElements() { const selected: ITemplate = { nodes: [], edges: [], type: 'elements' }; const selection = this.network.network.getSelection(true); selected.nodes = deepClone(this.nodes.filter(item => selection.nodes.indexOf(item.id) !== -1)); selected.edges = deepClone(this.edges.filter(item => selection.edges.indexOf(item.id) !== -1)); return selected; } /** * Function to paste copied stuff * @param position The Position, where the content should be inserted * @param useExistingNodesForEdges Decide, whether the connections should be although copied */ public paste( template: ITemplate, position: { x: number, y: number }, useExistingNodesForEdges: boolean) { const data = adaptPositions(adaptIDS(template, useExistingNodesForEdges), position); this.network.addNode(data.nodes); this.network.addEdge(data.edges); } /** * Function, will copy the Selection to the Clipboard * * @memberof GraphToolComponent */ public copySelectionToClipboard() { this.copyToClipboard(stringify(this.createTemplateOfSelectedElements(), this.parseFunctions)); } /** * Function, which will paste a String to the Clipboard * * @param {string} content The stringified Content * @memberof GraphToolComponent */ public copyToClipboard(content: string){ writeToClipboard(content); } public async readDataFromClipboard(){ if (navigator && (navigator as any).clipboard) { try { const text = await (navigator as any).clipboard.readText(); return text; } catch (err) { this.zemaService.showToast('warning', 'Clipboard', 'Failed Pasting Clipboard. Issue with the Rights'); this.zemaService.logger.error(err, 'Failed using Clipboard') } } else if (navigator && (navigator as any).permissions) { const permissionStatus = await (navigator as any).permissions.query({ name: 'clipboard-read' } as any) this.zemaService.logger.info('Current Permission State is ',permissionStatus.state) // Will be 'granted', 'denied' or 'prompt': const _this = this; // Listen for changes to the permission state permissionStatus.onchange = () => { _this.zemaService.logger.info('Current Permission State is ',permissionStatus.state) }; this.zemaService.showToast('warning', 'Clipboard', 'Failed Accessing Clipboard'); } return null; } public async pasteFromClipboard() { try { const data = await this.readDataFromClipboard(); if (data) { const clipboardData: ITemplate = parse(data, this.parseFunctions); this.paste(clipboardData, this.network.network.DOMtoCanvas(this.mousePos), false); this.zemaService.showToast('success', 'Clipboard', 'pasted'); } } catch (e) { this.zemaService.showToast('warning', 'Clipboard', 'Failed Pasting Clipboard'); } } /** * Function which will Create a Template for Mustache. This * Template can be added to the Sidebar (After Editing) to enter * makros. * * @returns a Mustache Template * @memberof GraphToolComponent */ public generateTemplateData() { const selected = this.createTemplateOfSelectedElements() const template: ISelectionTemplate = { keywords: ['replace me'], text: 'Displayed Label - Replace Me', template: { example: {}, schema: {}, mustache: stringify(selected, true), type: 'mustache' } }; return template; } /** * Function which will Create a Template for Mustache. This * Template can be added to the Sidebar (After Editing) to enter * makros. The Template will be copied as String to the clipboard * * @memberof GraphToolComponent */ public copyTemplateDataToClipboard() { // Write the serialized Template to the Clipboard writeToClipboard(stringify(this.generateTemplateData(), this.parseFunctions)); } }