Skip to content
Snippets Groups Projects
editor.tsx 18.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React from "react";
    
    import * as Interactions from "../interactions";
    
    import { Graph } from "../structures/graph/graph";
    
    import { loadGraphJson } from "../../../datasets";
    
    import { NodeDetails } from "./nodedetails";
    
    import { SpaceSelect } from "./spaceselect";
    
    import "./editor.css";
    
    import ReactForceGraph2d from "react-force-graph-2d";
    
    import { Node } from "../structures/graph/node";
    
    import { HistoryNavigator } from "./historynavigator";
    
    import { GraphElement } from "../structures/graph/graphelement";
    
    import { Link } from "../structures/graph/link";
    
    import { NodeTypesEditor } from "./nodetypeseditor";
    
    import { SpaceManager } from "./spacemanager";
    
    type propTypes = any;
    type stateTypes = {
        graph: Graph;
    
        selectedNode: Node;
        keys: { [name: string]: boolean };
    
    type clickPosition = {
        graph: { x: number; y: number };
        window: { x: number; y: number };
    };
    
    type positionTranslate = {
        x: number;
        y: number;
        z: number;
    };
    
    
    export class Editor extends React.PureComponent<propTypes, stateTypes> {
    
        private maxDistanceToConnect = 15;
    
        private defaultWarmupTicks = 100;
        private warmupTicks = 100;
    
        private renderer: any;
    
        private graphInFocus = false;
    
        constructor(props: propTypes) {
    
            this.loadGraph = this.loadGraph.bind(this);
    
            this.loadSpace = this.loadSpace.bind(this);
    
            this.extractPositions = this.extractPositions.bind(this);
    
            this.handleNodeClick = this.handleNodeClick.bind(this);
    
            this.onHistoryChange = this.onHistoryChange.bind(this);
    
            this.handleEngineStop = this.handleEngineStop.bind(this);
    
            this.handleKeyDown = this.handleKeyDown.bind(this);
            this.handleKeyUp = this.handleKeyUp.bind(this);
    
            this.forceUpdate = this.forceUpdate.bind(this);
    
            this.isHighlighted = this.isHighlighted.bind(this);
            this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
            this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
    
            this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
    
            this.handleNodeDrag = this.handleNodeDrag.bind(this);
            this.handleNodeDragEnd = this.handleNodeDragEnd.bind(this);
    
            this.handleLinkClick = this.handleLinkClick.bind(this);
    
            this.selectNode = this.selectNode.bind(this);
    
    
            this.renderer = React.createRef();
    
            // Set as new state
            this.state = {
                graph: undefined,
    
                connectOnDrag: false,
    
    
            Interactions.initInteractions();
    
    
            // Load initial space
            this.loadSpace("space");
    
        }
    
        /**
         * Loads a space from the database to the editor.
         * @param spaceId Id of space to load.
         * @returns Promise with boolean value that is true, if successful.
         */
        public loadSpace(spaceId: string): any {
            return loadGraphJson(spaceId).then(this.loadGraph);
        }
    
        /**
         * Loads another graph based on the data supplied. Note: Naming currently suggests that this only loads a GRAPH, not a SPACE. Needs further work and implementation to see if that makes sense or not.
         * @param data Serialized graph data.
         * @returns True, if successful.
         */
        public loadGraph(data: any): boolean {
    
            console.log("Starting to load new graph ...");
    
            console.log(data);
    
            const newGraph = Graph.parse(data);
    
            this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again
    
    
            // Is valid and parsed successfully?
    
            if (newGraph === undefined) {
                return false;
            }
    
    
                graph: newGraph,
    
            this.state.graph.onChangeCallbacks.push(this.onHistoryChange);
    
    
            // Subscribe to global key-press events
    
            document.onkeydown = this.handleKeyDown;
            document.onkeyup = this.handleKeyUp;
    
            document.onmousedown = this.handleMouseDown;
    
        private handleKeyDown(event: KeyboardEvent) {
            const key: string = event.key;
    
    
            const keys = this.state.keys;
            keys[key] = true;
    
            this.setState({
                keys: keys,
            });
    
    Maximilian Giller's avatar
    Maximilian Giller committed
    
            // Key events
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                // Only delete if 2d-graph is the focused element
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                this.selectNode(undefined);
    
            } else if (
                key === "Delete" &&
                this.state.selectedNode !== undefined &&
                this.graphInFocus
            ) {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                this.state.selectedNode.delete();
            }
    
        private handleMouseDown(event: any) {
            this.graphInFocus = false;
        }
    
    
        private handleKeyUp(event: KeyboardEvent) {
            const key: string = event.key;
    
    
            const keys = this.state.keys;
            keys[key] = false;
    
            this.setState({
                keys: keys,
            });
    
        /**
         * Handler for background click event on force graph. Adds new node by default.
         * @param event Click event.
         */
        private handleBackgroundClick(event: any, position: clickPosition) {
    
            // Is there really no node there? Trying to prevent small error, where this event is triggered, even if there is a node.
            const placeholderNode: Node = {
                id: undefined,
                x: position.graph.x,
                y: position.graph.y,
            } as unknown as Node;
            const nearestNode =
                this.state.graph.getClosestOtherNode(placeholderNode);
            if (nearestNode !== undefined && nearestNode.distance < 4) {
                this.handleNodeClick(nearestNode.node);
                return;
            }
    
    
            // Just deselect if shift key is pressed
            if (this.state.keys["Shift"]) {
                this.selectNode(undefined);
                return;
            }
    
            // Add new node
    
            const newNode = new Node();
    
            newNode.label = "Unnamed";
            (newNode as any).x = position.graph.x;
            (newNode as any).y = position.graph.y;
            (newNode as any).vx = 0;
            (newNode as any).vy = 0;
    
            newNode.add(this.state.graph);
    
        /**
         * Propagates the changed state of the graph.
         */
        private onHistoryChange() {
    
            if (this.state.selectedNode === undefined) {
                this.selectNode(undefined);
            } else {
                this.selectNode(
                    this.state.graph.getNode(this.state.selectedNode.id)
                );
            }
    
        /**
         * Should a given element be highlighted in rendering or not.
         * @param element Element that should, or should not be highlighted.
         * @returns True, if element should be highlighted.
         */
        private isHighlighted(element: GraphElement): boolean {
    
            if (this.state.selectedNode == undefined || element == undefined) {
    
                // Default to false if nothing selected.
                return false;
            }
    
            if (element.node) {
                // Is node
                return element.equals(this.state.selectedNode);
            } else if (element.link) {
                // Is link
                // Is it one of the adjacent links?
    
                const found = this.state.selectedNode.links.find(element.equals);
    
        /**
         * Calculates the corresponding coordinates for a click event for easier further processing.
         * @param event The corresponding click event.
         * @returns Coordinates in graph and coordinates in browser window.
         */
    
        private extractPositions(event: any): clickPosition {
    
                graph: this.renderer.current.screen2GraphCoords(
                    event.layerX,
                    event.layerY
                ),
    
                window: { x: event.clientX, y: event.clientY },
            };
        }
    
        private selectNode(node: Node) {
            this.setState({
                selectedNode: node,
            });
        }
    
    
        private handleNodeClick(node: Node) {
    
            if (this.state.keys["Shift"]) {
                // Connect two nodes when second select while shift is pressed
                if (this.state.selectedNode == undefined) {
                    // Have no node connected, so select
                    this.selectNode(node);
                } else if (!this.state.selectedNode.equals(node)) {
    
                    const selected = this.state.selectedNode;
    
                    // Already have *other* node selected, so connect
                    this.state.selectedNode.connect(node);
    
                    // Re-select original node for easier workflow
                    this.selectNode(selected);
    
                }
            } else if (this.state.keys["Control"]) {
                // Delete node when control is pressed
    
                node.delete();
            } else {
    
                // By default, simply select node
                this.selectNode(node);
    
            this.forceUpdate();
    
        private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) {
            // add ring just for highlighted nodes
            if (this.isHighlighted(node)) {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                // Outer circle
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                ctx.arc(node.x, node.y, 4 * 0.7, 0, 2 * Math.PI, false);
                ctx.fillStyle = "white";
                ctx.fill();
    
                // Inner circle
                ctx.beginPath();
                ctx.arc(node.x, node.y, 4 * 0.3, 0, 2 * Math.PI, false);
                ctx.fillStyle = node.type.color;
    
                ctx.fill();
            }
    
            // Draw image
            const imageSize = 12;
            if (node.icon !== undefined) {
                const img = new Image();
    
                img.src = node.icon;
    
    
                ctx.drawImage(
                    img,
                    node.x - imageSize / 2,
                    node.y - imageSize / 2,
                    imageSize,
                    imageSize
                );
            }
    
            // Draw label
    
            const isNodeRelatedToSelection: boolean =
                this.state.selectedNode === undefined ||
                this.isHighlighted(node) ||
                this.state.selectedNode.neighbors.includes(node);
    
            if (this.state.visibleLabels && isNodeRelatedToSelection) {
    
                const label = node.label;
                const fontSize = 11 / globalScale;
                ctx.font = `${fontSize}px Sans-Serif`;
                const textWidth = ctx.measureText(label).width;
                const bckgDimensions = [textWidth, fontSize].map(
                    (n) => n + fontSize * 0.2
                ); // some padding
    
                const nodeHeightOffset = imageSize / 3 + bckgDimensions[1];
                ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
                ctx.fillRect(
                    node.x - bckgDimensions[0] / 2,
                    node.y - bckgDimensions[1] / 2 + nodeHeightOffset,
                    ...bckgDimensions
                );
    
                ctx.textAlign = "center";
                ctx.textBaseline = "middle";
                ctx.fillStyle = "white";
                ctx.fillText(label, node.x, node.y + nodeHeightOffset);
            }
    
            // TODO: Render label as always visible
        }
    
    
        private handleLinkCanvasObject(link: any, ctx: any, globalScale: any): any {
    
            // Links already initialized?
            if (link.source.x === undefined) {
                return undefined;
            }
    
            // Draw gradient link
            const gradient = ctx.createLinearGradient(
                link.source.x,
                link.source.y,
                link.target.x,
                link.target.y
            );
            // Have reversed colors
            // Color at source node referencing the target node and vice versa
            gradient.addColorStop("0", link.target.type.color);
            gradient.addColorStop("1", link.source.type.color);
    
    
            let lineWidth = 0.5;
            if (this.isHighlighted(link)) {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                lineWidth = 2;
    
            }
            lineWidth /= globalScale; // Scale with zoom
    
    
            ctx.beginPath();
            ctx.moveTo(link.source.x, link.source.y);
            ctx.lineTo(link.target.x, link.target.y);
            ctx.strokeStyle = gradient;
    
            ctx.lineWidth = lineWidth;
    
        private handleNodeDrag(node: Node, translate: positionTranslate) {
    
            // Should run connect logic?
            if (!this.state.connectOnDrag) {
                return;
            }
    
    
            const closest = this.state.graph.getClosestOtherNode(node);
    
            // Is close enough for new link?
            if (closest.distance > this.maxDistanceToConnect) {
                return;
            }
    
            // Does link already exist?
            if (node.neighbors.includes(closest.node)) {
                return;
            }
    
            // Add link
            node.connect(closest.node);
            this.forceUpdate();
        }
    
        private handleNodeDragEnd(node: Node, translate: positionTranslate) {
            return;
        }
    
    
        private handleLinkClick(link: Link) {
    
            if (this.state.keys["Control"]) {
                // Delete link when control is pressed
                link.delete();
                this.forceUpdate();
            }
    
        private handleEngineStop() {
            // Only do something on first stop for each graph
            if (this.warmupTicks <= 0) {
                return;
            }
    
            this.warmupTicks = 0; // Only warm up once, so stop warming up after the first freeze
            this.state.graph.storeCurrentData("Initial state", false);
    
            this.forceUpdate();
        }
    
    
        render(): React.ReactNode {
            return (
                <div id="ks-editor">
                    <h1>Interface</h1>
    
                    <SpaceSelect onLoadSpace={this.loadSpace} />
    
                    <div id="content">
    
                        <div id="force-graph-renderer">
                            {this.state.graph ? (
                                <ReactForceGraph2d
                                    ref={this.renderer}
                                    width={2000}
    
                                    graphData={{
                                        nodes: this.state.graph.data.nodes,
                                        links: this.state.graph.links,
                                    }}
    
                                    onNodeClick={this.handleNodeClick}
                                    autoPauseRedraw={false}
                                    cooldownTicks={0}
                                    warmupTicks={this.warmupTicks}
                                    onEngineStop={this.handleEngineStop}
                                    nodeCanvasObject={this.handleNodeCanvasObject}
                                    nodeCanvasObjectMode={() => "after"}
                                    linkCanvasObject={this.handleLinkCanvasObject}
                                    linkCanvasObjectMode={() => "replace"}
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                                    nodeColor={(node) => (node as Node).type.color}
    
                                    onNodeDrag={this.handleNodeDrag}
                                    onNodeDragEnd={this.handleNodeDragEnd}
                                    onLinkClick={this.handleLinkClick}
                                    onBackgroundClick={(event) =>
                                        this.handleBackgroundClick(
                                            event,
                                            this.extractPositions(event)
                                        )
                                    }
                                />
                            ) : undefined}
                        </div>
    
                        <div id="sidepanel">
    
                            <HistoryNavigator
                                spaceId="space"
                                historyObject={this.state.graph}
    
                                allTypes={
                                    this.state.graph ? this.state.graph.types : []
                                }
    
                                onChange={this.forceUpdate}
    
                            <h3>Node types</h3>
                            <NodeTypesEditor
                                onChange={this.forceUpdate}
                                graph={this.state.graph}
                            />
                            <hr />
                            <h3>Settings</h3>
                            <input
                                id="node-labe-visibility"
                                type={"checkbox"}
                                checked={this.state.visibleLabels}
                                onChange={(event) => {
                                    const newValue = event.target.checked;
                                    if (newValue == this.state.visibleLabels) {
                                        return;
                                    }
    
                                    this.setState({
                                        visibleLabels: newValue,
                                    });
                                }}
                            />
                            <label htmlFor="node-labe-visibility">
                                Node labels
                            </label>
                            <br />
                            <input
                                id="connect-on-drag"
                                type={"checkbox"}
                                checked={this.state.connectOnDrag}
                                onChange={(event) => {
                                    const newValue = event.target.checked;
                                    if (newValue == this.state.connectOnDrag) {
                                        return;
                                    }
    
                                    this.setState({
                                        connectOnDrag: newValue,
                                    });
                                }}
                            />
                            <label htmlFor="connect-on-drag">
                                Connect nodes when dragged
                            </label>
    
                            <hr />
                            <ul className="instructions">
                                <li>Click background to create node</li>
                                <li>SHIFT+Click background to clear selection</li>
                                <li>Click node to select and edit</li>
                                <li>CTRL+Click node to delete</li>
                                <li>CTRL+Click link to delete</li>
                                <li>SHIFT+Click a second node to connect</li>
                                {this.state.connectOnDrag ? (
                                    <li>
                                        Drag node close to other node to connect
                                    </li>
                                ) : (
                                    ""
                                )}
                                <li>DELETE to delete selected node</li>
                                <li>ESCAPE to clear selection</li>
                            </ul>