nope/resources/ui/gojs/editor.tsx
Martin Karkowski 838d910c2f adding gojs
2020-12-01 13:05:35 +01:00

1085 lines
33 KiB
TypeScript

/**
* @author Martin Karkowski
* @email m.karkowski@zema.de
* @create date 2020-11-25 08:19:39
* @modify date 2020-11-25 12:05:20
* @desc [description]
*/
import * as React from "react";
import { Button, ListGroup } from "react-bootstrap";
import { v4 as generateID } from "uuid";
import {
parseWithFunctions,
stringifyWithFunctions
} from "../../../lib/helpers/jsonMethods";
import {
deepClone,
rgetattr,
rsetattr
} from "../../../lib/helpers/objectMethods";
import { IJsonSchema } from "../../../lib/types/IJSONSchema";
import { generateDefaultSelection } from "../graph/defaults/default.elements";
import { generateGraphOptions } from "../graph/defaults/default.graph-options";
import { defaultHotkeys } from "../graph/defaults/default.hotkeys";
import { defaultToolbar } from "../graph/defaults/default.toolbar";
import { readDataFromClipboard, writeToClipboard } from "../helpers/clipboard";
import { getCurrentThemeColors } from "../helpers/colors";
import DynamicLayout, { IDynamicLayoutProps } from "../layout/dynamicLayout";
import { ILayout } from "../layout/interfaces/ILayout";
import { ISelection } from "../layout/interfaces/ISelection";
import { ITab } from "../layout/interfaces/ITab";
import { ITabEntry } from "../layout/interfaces/ITabEntry";
import Selection from "../layout/selection";
import TabEntry, { ITabProps } from "../layout/tabs";
// Graph related imports:
export interface GraphicalEditorComponentProps<N, E> {
graphOptions?: any;
network?: any;
addEdgeCallback?: (edgeData: E, callback: (edgeData: E) => void) => void;
editOnSelect?: boolean;
editOnChange?: boolean;
parseFunctions?: boolean;
enableContextMenu?: boolean;
enableEditing?: boolean;
hidePanelOnDeselect?: boolean;
hideToolbar?: boolean;
hideRightPanel?: boolean;
allowSelfConnection?: boolean;
allowDoubleConnections?: boolean;
edgeTypes?: any[];
showEdgeSelction?: boolean;
}
export interface GraphicalEditorComponentState {
update: null;
}
class GraphicalEditorComponent<N, E> extends React.Component<
GraphicalEditorComponentProps<N, E>,
GraphicalEditorComponentState
> {
/**
* The Element containing the Network.
*/
public LayoutHandler: ILayout;
public TabHandler: ITabEntry;
public theme: {
colors: {
primary: string;
secondary: string;
success: string;
info: string;
warning: string;
danger: string;
light: string;
dark: string;
};
font: {
size: number;
type: string;
};
};
constructor(props) {
super(props);
this.theme = getCurrentThemeColors();
}
public tabs: { [index: string]: go.GraphLinksModel } = {};
/**
* Function to add an Edge.
* @param edgeData
* @param callback
*/
protected async _addEdge(edgeData: any, callback: (edge: any) => void) {
// Defaulty prevent self connections.
if (this.props.allowSelfConnection || edgeData.from !== edgeData.to) {
let addEdge = true;
// If multiple Connections from one node to the same node are
// for bidden, test if there already exists such an edge.
if (!this.props.allowDoubleConnections) {
this.Network.edges.forEach((edge) => {
if (edge.from === edgeData.from && edge.to === edgeData.to) {
addEdge = false;
}
});
}
// If adding an Edge is allowed => Add the Edge.
if (addEdge) {
// Manually add an ID to the Edge
edgeData.id = generateID();
// Extract the From and To Node.
const from = this.Network.getNode(edgeData.from);
const to = this.Network.getNode(edgeData.to);
const _this = this;
const _addEdge = (type: IEdgeTypeDefinition<N, E>) => {
if (typeof type.customAddFunction == "function") {
type.customAddFunction(from, to, _this.Network);
} else {
// Define the Edge Object
const edge = Object.assign(deepClone(type.visual), {
from: edgeData.from,
to: edgeData.to
});
// Add the Edge.
callback(edge);
}
};
switch (edgeData.type) {
case "automatic":
// Search for Possible Connection Types.
const possibleEdges: IEdgeTypeDefinition<N, E>[] = [];
for (const edgeType of this.props.edgeTypes || []) {
if (
(edgeType.fromTypes.includes(from.type) ||
edgeType.fromTypes.length === 0) &&
(edgeType.toTypes.includes(to.type) ||
edgeType.toTypes.length === 0)
) {
possibleEdges.push(edgeType);
}
}
// Test if Edges has been found
switch (possibleEdges.length) {
case 0:
this.LayoutHandler.showToast(
"No Matching Edge Type has been found. Please select the corresponding Edge Type manually",
"info"
);
return;
case 1:
// Define the New Edge.
return _addEdge(possibleEdges[0]);
default:
if (this.props.showEdgeSelction) {
// Create a Popup, on which the corresponding edge can be selected:
try {
const type: IEdgeTypeDefinition<
N,
E
> = await this.LayoutHandler.getDialogData({
content: {
props: {
possibleEdges
},
component(props: {
possibleEdges: IEdgeTypeDefinition<N, E>[];
onSubmit: (data: IEdgeTypeDefinition<N, E>) => void;
onCancel: (error: any) => void;
}) {
return (
<>
Select a possible edge by clicking on the item
<ListGroup>
{props.possibleEdges.map((item, idx) => {
<ListGroup.Item
key={idx}
onClick={(_) => props.onSubmit(item)}
>
{item.type}
</ListGroup.Item>;
})}
</ListGroup>
<Button
variant="danger"
onClick={(e) => props.onCancel(e)}
>
Cancel{" "}
</Button>
</>
);
}
},
header: "Please select an edge-type!",
closeButton: true
});
// Add the Edge itself.
return _addEdge(type);
} catch (e) {
// The User has aborted the selection.
return;
}
}
this.LayoutHandler.showToast(
"Mulitple Valid Edge Type has been found. Please select the corresponding Edge Type manually",
"info"
);
}
return;
default:
for (const type of this.props.edgeTypes || []) {
if (
edgeData.type === type.type &&
(type.fromTypes.includes(from.type) ||
type.fromTypes.length === 0) &&
(type.toTypes.includes(to.type) || type.toTypes.length === 0)
) {
// Quit the Method
return _addEdge(type);
}
}
this.LayoutHandler.showToast(
"You are not allowed to connect the desired Nodes with this edges",
"error"
);
break;
}
} else {
this.LayoutHandler.showToast(
"Double Connections not allowed! You can not connect these elements again",
"error"
);
}
} else {
this.LayoutHandler.showToast(
"Self-Connections forbidden! You can not connect elements to itself",
"error"
);
}
}
protected async _editNodes(
_template: IBasicTemplate<N, E>,
mode: "add" | "update" = "update"
) {
// Defaulty prevent self connections.
if (_template?.nodes.length > 0) {
try {
if (_template.nodes[0].editorComponentSelector && false) {
} else {
// Open up the corresponding Node Panel:
_template.nodes[0] = await this.LayoutHandler.getDialogData({
content: {
component: "DynamicForm",
props: {
schema: {
type: "object",
properties: {
label: {
description: "Label of the Node",
type: "string"
},
title: {
description: "Tooltip, presented on Hovering",
type: "string"
}
// color: {
// description: 'Color of the Node.',
// type: 'string'
// }
},
required: ["label"]
} as IJsonSchema,
uiSchema: {
// color: {
// "ui:widget": "color"
// }
},
data: Object.assign(
generateColors(_template.nodes[0].shape, this.theme),
_template.nodes[0]
)
}
}
});
}
switch (mode) {
case "add":
this.Network.addNode(_template.nodes);
this.Network.addEdge(_template.edges);
break;
case "update":
this.Network.updateNode(_template.nodes);
this.Network.updateEdge(_template.edges);
break;
}
// Tabs
this.tabs[this.TabSettings.tabs.active] = this.Network.getData();
} catch (error) {
console.log(error);
}
} else {
this.LayoutHandler.showToast("Please Select a Node Before.", "error");
}
}
public initEditor(ref: React.RefObject<any>) {
if (process.browser) {
const _this = this;
// Based on the Provided Options =>
// select the corresponding graph options,
// either use th provided one or use the
// default options.
if (this.props.graphOptions) {
this.graphOptions = this.props.graphOptions;
} else {
this.graphOptions = generateGraphOptions();
}
if (!rgetattr(this.graphOptions, "manipulation.enabled", false)) {
rsetattr(this.graphOptions, "manipulation.enabled", false);
}
// Generate the Default Callback for adding an Edge
rsetattr(this.graphOptions, "manipulation.addEdge", (data, callback) => {
_this._addEdge(data, callback).catch(console.error);
});
// Generate the Default Callback for adding a Node
rsetattr(this.graphOptions, "manipulation.addNode", (data, callback) => {
_this._addNode(data).catch(console.error);
});
// Create the Network
this.Network = new UndoRedoGraph(ref, this.graphOptions);
// Add a new item if possible.
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 nodes = getSelectedElements(_this.Network, false);
_this._editNodes({
edges: [],
nodes,
type: "element"
});
}
} 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);
}
};
if (!this.Network) {
throw Error("Something went wrong");
}
// Make the Default behaviour adding new Nodes
this.Network.on("doubleClick", (params) => {
if (_this.props.enableEditing || true) {
// 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.LayoutHandler.showToast("Copied");
writeToClipboard(
stringifyWithFunctions(_this.createTemplateOfSelectedElements())
);
} catch (e) {}
});
this.Network.on("onpaste", async (event) => {
try {
const data = await readDataFromClipboard();
const mousePosition = _this.Network.network.DOMtoCanvas({
x: _this.LayoutHandler.currentMousePosition.offsetX,
y: _this.LayoutHandler.currentMousePosition.offsetY
});
_this.paste(parseWithFunctions(data), mousePosition, false);
_this.LayoutHandler.showToast("Pasted");
} catch (e) {
_this.LayoutHandler.showToast("Failed Pasting", "error");
console.error(e);
}
});
// // Make shure the Sidepanel is working correctly
this.Network.on("select", (params) => {
// if (_this.graphOptions.enableEditing){
// if (_this.graphOptions.editOnSelect && _this.graphOptions.hidePanelOnDeselect && params.nodes.length === 0 && params.edges.length === 0){
// _this.layout.panels.right.hide();
// } else if (_this.graphOptions.editOnSelect){
// editItemIfPossible(params);
// }
// }
});
// enableClusterPreview(this);
// this.network.addNode(this._nodes);
// this.network.addEdge(this._edges);
// editor.resize();
console.log("TABS", this.tabs);
this.Network.loadData(
this.tabs[this.TabSettings?.tabs?.active] || DEFAULT_NETWORK()
);
// Add all Addons as required.
this.addGraphAddons(this.Network);
}
return this.Network;
}
/**
* Function, which is used to provide a
*/
public createTemplateOfSelectedElements() {
const selected: IBasicTemplate<N, E> = {
nodes: [],
edges: [],
type: "element"
};
const selection = this.Network.network.getSelection(true);
selected.nodes = deepClone(
this.Network.nodes.filter(
(item) => selection.nodes.indexOf(item.id) !== -1
)
);
selected.edges = deepClone(
this.Network.edges.filter(
(item) => selection.edges.indexOf(item.id) !== -1
)
);
return selected;
}
public paste(
template: IBasicTemplate<N, E>,
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 to Update the Data of a Node.
* @param selection The Selected Node.
*/
public async updateNode(selection: Array<IBaseNodeOptions<N>>) {
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.layout.showToast('Editor is Trying to open an Unkown Edit-Component', 'error');
// }
}
protected async _addNode(pos: { x: number; y: number }) {
// Defaulty prevent self connections.
if (this._template?.nodes.length > 0) {
try {
// Adapt its IDs. (Creates a Copy)
let _template = adaptIDS(this._template);
// Adapt its Position
_template = adaptPositions(_template, pos);
// Remove the Initial Label:
delete _template.nodes[0].label;
await this._editNodes(_template, "add");
} catch (error) {
console.error(error);
}
} else {
this.LayoutHandler.showToast("Please Select a Node Before.", "error");
}
}
public EditorSettings: GraphicalEditorComponentProps<N, E>;
public LayoutSettings: IDynamicLayoutProps<IGraphCallbackData<N, E>>;
public TabSettings: ITabProps;
protected _template: IBasicTemplate<N, E>;
public selectTemplate(template: IBasicTemplate<N, E>) {
this._template = template;
}
public initializeGraph() {
const _this = this;
// Assign a default Network:
const networkToRender: INetwork<N, E> =
this.props.network ||
this.tabs[this.TabSettings?.tabs?.active] ||
DEFAULT_NETWORK();
// Initally store the Network to Render
this.tabs[networkToRender.id] = networkToRender;
this.TabSettings = {
onMount(item) {
_this.TabHandler = item;
},
async onNewTab() {
// Function to create a new tab.
try {
if (_this.TabHandler.tabs.length == 0) {
const tab = {
delteable: true,
id: generateID(),
label: "Unsaved Content"
};
await _this.TabHandler.createTab(tab);
// Store the Content of the tab:
_this.tabs[tab.id] = _this.Network.getData();
}
const label = await _this.LayoutHandler.getDialogData({
header: "Enter Network-Name",
content: {
component: "DynamicForm",
props: {
schema: {
type: "string",
description: "Name of the Element"
}
}
}
});
const id = Date.now().toString();
// const label = id;
_this.tabs[id] = DEFAULT_NETWORK();
return {
id,
label,
delteable: true
};
} catch (error) {
return false;
}
},
async onTabSelect(oldTab: ITab, newTab: ITab) {
if (_this.Network) {
// Contains the Main Logic for selecting another tab
// Therefore
if (oldTab) {
_this.tabs[oldTab.id] = _this.Network.getData();
}
// Use the content of the new Tab as rendering:
_this.Network.loadData(_this.tabs[newTab.id]);
}
return true;
},
async onTabDelete(tab: ITab, force: boolean) {
// A Tab should be deleted
if (force) {
return true;
}
try {
const saveData = await _this.LayoutHandler.getDialogData<boolean>({
header: "Close Tab",
content: {
component: (props) => {
return (
<>
<p>
You are about to close the tab "{tab.label}". Do you want
to save changes?
</p>
<Button
variant="success"
onClick={(_) => props.onSubmit(true)}
>
Yes
</Button>{" "}
<Button
variant="danger"
onClick={(_) => props.onSubmit(false)}
>
No
</Button>{" "}
<Button
variant="secondary"
onClick={(_) => props.onCancel(new Error("Canceled"))}
>
Cancel
</Button>{" "}
</>
);
},
props: {}
}
});
// The variable save Data contains the info, whether the user wants to save or discard the changes
return true;
} catch (error) {
return false;
}
},
async onNoTabSelected() {
// Nothing to Display!
if (_this.Network) {
console.log("No Tab");
// Use the content of the new Tab as rendering:
_this.Network.loadData(DEFAULT_NETWORK());
}
},
async onTabEdit(tab) {
const label = await _this.LayoutHandler.getDialogData<string>({
header: "Enter Network-Name",
content: {
component: "DynamicForm",
props: {
schema: {
type: "string",
description: "Name of the Element"
},
data: tab.label
}
}
});
tab.label = label;
return tab;
},
tabs: {
active: "-1",
allowNewTabs: true,
items: []
}
};
this.LayoutSettings = {
components: [
{
component() {
const graph = React.useRef(null);
// Similar to componentDidMount and componentDidUpdate:
// Createa a Function, that if mounted defines the Network.
React.useEffect(() => {
// Define the Network.
_this.initEditor(graph);
// Return a Function, that will destroy the network.
return () => {
_this.tabs[
_this.TabSettings.tabs.active
] = _this.Network.getData();
// Destroy the Network.
_this.Network.destroy();
_this.Network = null;
};
});
return (
<>
{React.createElement(TabEntry, { ..._this.TabSettings })}
<div
ref={graph}
style={{ height: "100%" }}
onMouseEnter={(e) => {
// Enable Hotkeys
_this.LayoutHandler.hotkeysEnabled = true;
}}
onMouseLeave={(e) => {
// Disable Hotkeys
_this.LayoutHandler.hotkeysEnabled = false;
}}
></div>
</>
);
},
gridSettings: {
h: 6,
w: 11,
x: 5,
y: 0,
minW: 3,
minH: 3
},
id: "main",
label: "Editor",
props: {},
visible: true,
bg: "light",
text: "dark"
},
{
component(props: { selection: ISelection<IBasicTemplate<N, E>> }) {
return (
<Selection<IBasicTemplate<N, E>>
selection={props.selection}
allowUserSelect={true}
onItemSelected={(template) => _this.selectTemplate(template)}
></Selection>
);
},
gridSettings: {
h: 3,
w: 4,
x: 0,
y: 0,
maxW: 5
},
id: "selection",
label: "Selection",
props: {
selection: generateDefaultSelection()
},
hideable: true,
bg: "light",
visible: true,
text: "dark",
showLabel: true
},
{
component() {
const [img, setImg] = React.useState();
const [pos, setPos] = React.useState<{
left: number;
top: number;
height: number;
width: number;
}>({
left: 0,
top: 0,
width: 0,
height: 0
});
const [style, setStyle] = React.useState<any>();
const radar = React.useRef(null);
const image = React.useRef(null);
// Similar to componentDidMount and componentDidUpdate:
// Createa a Function, that if mounted subscribe to changes
// of the Nework.
React.useEffect(() => {
const wait = () => {
return new Promise((resolve, reject) => {
_this.Network.once("afterDrawing", resolve);
});
};
const updateImage = async () => {
// Store the current View Settings
const scale = _this.Network.network.getScale();
const translate = _this.Network.network.getViewPosition();
// Zoom out for the Image.
_this.Network.fit({ animation: false });
// Wait for the Network to Redraw
await wait();
const canvas = _this.Network.network.body.container.getElementsByTagName(
"canvas"
)[0];
const imgData = canvas.toDataURL("image/png");
setImg(imgData);
/**
* Extract the Widht and Height
*/
const {
clientWidth,
clientHeight
} = _this.Network.network.body.container;
// Store the Position.
setPos({
left: translate.x - clientWidth / scale / 2,
top: translate.y - clientHeight / scale / 2,
width: clientWidth / scale,
height: clientHeight / scale
});
// Use inital View
_this.Network.network.moveTo({
position: translate,
scale: scale,
animation: false
});
};
const getScaling = (start, size, current, scale = true) => {
if (scale) {
return Math.min(1, Math.max(0, (current - start) / size));
}
return (current - start) / size;
};
/**
* Function to draw the current Positon of the View.
*/
const drawRadar = () => {
if (radar?.current && image?.current) {
const displayedWidth = image.current.clientWidth;
const displayedHeight = image.current.clientHeight;
/**
* Extract the Widht and Height
*/
const {
clientWidth,
clientHeight
} = _this.Network.network.body.container;
// Determine visible Nodes:
const scale = _this.Network.network.getScale();
const translate = _this.Network.network.getViewPosition();
// Contains the Left Position of the View.
// The following Transformation is used:
// _____
// |(top,left)-Original = 0,0
// | ___
// | |(top, left of the View)
//
const current = {
left: translate.x - clientWidth / scale / 2,
top: translate.y - clientHeight / scale / 2,
width: clientWidth / scale,
height: clientHeight / scale
};
const top = getScaling(pos.top, pos.height, current.top);
const bottom = getScaling(
pos.top,
pos.height,
current.top + current.height
);
const height = bottom - top;
const left = getScaling(pos.left, pos.width, current.left);
const right = getScaling(
pos.left,
pos.width,
current.left + current.width
);
const width = right - left;
setStyle({
position: "absolute",
backgroundColor: "rgba(16, 84, 154, 0.26)",
top:
(
image.current.offsetTop +
top * displayedHeight
).toString() + "px",
left:
(
image.current.offsetLeft +
left * displayedWidth
).toString() + "px",
height: (height * displayedHeight).toString() + "px",
width:
Math.max(
0,
width * displayedWidth - image.current.offsetLeft
).toString() + "px"
});
}
};
const listeners: Partial<{ [K in IEvent]: any }> = {
dataUpdate: updateImage,
afterDrawing: drawRadar
};
const subscriptions: Array<() => void> = [];
if (_this.Network) {
for (const key in listeners) {
subscriptions.push(
_this.Network.on(key as IEvent, listeners[key])
);
}
}
// Return a Function, that will destroy the network.
return () => {
for (const unsubscribe of subscriptions) {
unsubscribe();
}
};
});
return (
<div style={{ overflow: "hidden" }}>
<img
ref={image}
src={img}
style={{
position: "absolute",
maxHeight: "100%",
maxWidth: "100%"
}}
></img>
<div ref={radar} style={style}></div>
</div>
);
},
gridSettings: {
h: 2,
w: 4,
x: 0,
y: 4,
maxW: 5
},
id: "minimap",
label: "Minimap",
props: {},
hideable: true,
bg: "light",
visible: true,
text: "dark",
showLabel: true
},
{
component() {
return <>Preview</>;
},
gridSettings: {
h: 3,
w: 3,
x: 3,
y: 5
},
id: "preview",
label: "Preview",
props: {},
hideable: true,
bg: "light",
visible: false,
text: "dark"
}
],
layoutSettings: {
width: process.browser ? window.innerWidth : 1920,
autoSize: false,
preventCollision: false,
cols: 15,
compactType: "horizontal",
onLayoutChange() {
if (_this.Network) {
_this.Network.resize();
}
}
},
generateData() {
// Get the Current Selection.
const selection = _this.Network.network.getSelection(true);
const ret: IGraphCallbackData<N, E> = {
network: _this.Network,
layout: _this.LayoutHandler,
selectedEdges: selection.edges,
selectedNodes: selection.nodes,
tabs: _this.TabHandler
};
Object.defineProperty(ret, "network", {
get() {
return _this.Network;
}
});
Object.defineProperty(ret, "layout", {
get() {
return _this.LayoutHandler;
}
});
return ret;
},
onResize() {
if (_this.Network) {
_this.Network.resize();
}
},
onMount(layout) {
_this.LayoutHandler = layout;
},
toolbar: defaultToolbar(),
hotkeys: defaultHotkeys()
};
return this.LayoutSettings;
}
public requestRerender() {
this.setState({
update: null
});
}
public componentDidMount() {
const _this = this;
}
public render() {
this.initializeGraph();
return React.createElement(DynamicLayout, { ...this.LayoutSettings });
}
}
export default GraphicalEditorComponent;