Skip to content
Snippets Groups Projects
editor.tsx 25.3 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React from "react";
    
    import { DynamicGraph } from "./graph";
    
    import { loadGraphJson } from "../common/datasets";
    import { NodeDetails } from "./components/nodedetails";
    import { SpaceSelect } from "./components/spaceselect";
    
    import "./editor.css";
    
    Maximilian Giller's avatar
    Maximilian Giller committed
    import { ForceGraph2D } from "react-force-graph";
    
    import { Node } from "../common/graph/node";
    
    import { HistoryNavigator } from "./components/historynavigator";
    
    import { GraphElement } from "../common/graph/graphelement";
    import { Link } from "../common/graph/link";
    
    import { NodeTypesEditor } from "./components/nodetypeseditor";
    import { SpaceManager } from "./components/spacemanager";
    import { SelectLayer } from "./components/selectlayer";
    
    import { GraphData } from "../common/graph/graph";
    import { NodeType } from "../common/graph/nodetype";
    
    type propTypes = {
        spaceId: string;
    };
    
    type stateTypes = {
    
        /**
         * Graph structure holding the basic information.
         */
    
    
        /**
         * Should labels on nodes be rendered, or none at all.
         */
    
    
        /**
         * Should feature be enabled, that nodes get connected with a link of dragged close enough to each other?
         */
    
    
        /**
         * Collection of all currently selected nodes. Can also be undefined or empty.
         */
        selectedNodes: Node[];
    
        /**
         * True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key.
         */
    
    
        /**
         * Current width of graph object. Used to specifically adjust and correct the graph size.
         */
    
    Maximilian Giller's avatar
    Maximilian Giller committed
    type graphCoordinates = {
    
        x: number;
        y: number;
    
    Maximilian Giller's avatar
    Maximilian Giller committed
    };
    
    /**
     * Easy to access format for translated positions of a click event.
     */
    
    type clickPosition = {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
        graph: graphCoordinates;
        window: graphCoordinates;
    
    /**
     * Knowledge space graph editor. Allows easy editing of the graph structure.
     */
    
    export class Editor extends React.PureComponent<propTypes, stateTypes> {
    
        private maxDistanceToConnect = 15;
    
        private defaultWarmupTicks = 100;
        private warmupTicks = 100;
    
        private renderer: React.RefObject<any>;
        private graphContainer: React.RefObject<HTMLDivElement>;
    
    
        /**
         * True, if the graph was the target of the most recent click event.
         */
    
        private graphInFocus = false;
    
        constructor(props: propTypes) {
    
    
            // Making sure, all functions retain the proper this-bind
    
            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.onGraphDataChange = this.onGraphDataChange.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.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
            this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
    
            this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
    
            this.handleNodeDrag = this.handleNodeDrag.bind(this);
    
            this.handleElementRightClick = this.handleElementRightClick.bind(this);
    
            this.selectNode = this.selectNode.bind(this);
    
            this.handleResize = this.handleResize.bind(this);
    
    Maximilian Giller's avatar
    Maximilian Giller committed
            this.handleBoxSelect = this.handleBoxSelect.bind(this);
    
            this.handleNodeTypeSelect = this.handleNodeTypeSelect.bind(this);
    
    
            this.renderer = React.createRef();
    
            this.graphContainer = React.createRef();
    
            // Set as new state
            this.state = {
                graph: undefined,
    
                connectOnDrag: false,
    
                selectedNodes: [], // TODO: Why was undefined allowed here?
    
        /**
         * Tries to load initial graph after webpage finished loading.
         */
    
        componentDidMount() {
            if (this.props.spaceId !== undefined) {
                // Load initial space
                this.loadSpace(this.props.spaceId);
            }
    
        }
    
        /**
         * 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: GraphData): boolean {
    
            console.log("Starting to load new graph ...");
    
            console.log(data);
    
            const graph = new DynamicGraph();
            graph.fromSerializedObject(data);
    
            this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again
    
    
            graph.onChangeCallbacks.push(this.onGraphDataChange);
    
            // Subscribe to global events
    
            document.onkeydown = this.handleKeyDown;
            document.onkeyup = this.handleKeyUp;
    
            document.onmousedown = this.handleMouseDown;
    
            window.onresize = this.handleResize;
    
            this.handleResize();
    
        /**
         * Processes page wide key down events. Stores corresponding key as pressed in state.
         *
         * Also triggers actions corresponding to shortcuts.
         */
    
        private handleKeyDown(event: KeyboardEvent) {
            const key: string = event.key;
    
    
            const keys = this.state.keys;
            keys[key] = true;
    
            this.setState({
                keys: keys,
            });
    
            this.handleShortcutEvents(key);
        }
    
        /**
         * Triggers actions that correspond with certain shortcuts.
         *
         * @param key Newly pressed key.
         */
        private handleShortcutEvents(key: string) {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                this.selectNode(undefined);
    
            } else if (
                key === "Delete" &&
    
                this.graphInFocus // Only delete if 2d-graph is the focused element
    
        /**
         * Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes.
         */
        private deleteSelectedNodes() {
    
            const selectedNodes = this.state.selectedNodes;
    
            if (selectedNodes.length == 1) {
                selectedNodes[0].delete();
                selectedNodes.pop();
                this.selectNodes(selectedNodes);
            } else {
                selectedNodes.forEach((node: Node) => node.delete());
                this.deselect();
    
        /**
         * Processes page wide mouse down events.
         */
        private handleMouseDown() {
    
        /**
         * Processes page wide key up events. Stores corresponding key as not-pressed in state.
         */
    
        private handleKeyUp(event: KeyboardEvent) {
            const key: string = event.key;
    
    
            const keys = this.state.keys;
            keys[key] = false;
    
            this.setState({
                keys: keys,
            });
    
        /**
         * Processes resize window event. Focusses on resizing the graph accordingly.
         */
    
        private handleResize() {
            const newGraphWidth = this.graphContainer.current.clientWidth;
            this.setState({
                graphWidth: newGraphWidth,
            });
        }
    
    
        /**
         * Handler for background click event on force graph. Adds new node by default.
         * @param event Click event.
         */
    
        private handleBackgroundClick(event: MouseEvent, 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 nearestNode = this.state.graph.getClosestNode(
                position.graph.x,
                position.graph.y
            );
    
            if (nearestNode !== undefined && nearestNode.distance < 4) {
                this.handleNodeClick(nearestNode.node);
                return;
            }
    
    
    Maximilian Giller's avatar
    Maximilian Giller committed
            // Just deselect if control key is pressed
    
            if (this.state.keys["Control"]) {
    
                this.selectNode(undefined);
                return;
            }
    
            // Add new node
    
            const node = this.state.graph.createNode(
                undefined,
                position.graph.x,
                position.graph.y,
                0,
                0
            );
            this.forceUpdate(); // TODO: Remove?
    
    
            // Select newly created node
            if (this.state.keys["Shift"]) {
                // Simply add to current selection of shift is pressed
    
                this.toggleNodeSelection(node);
    
        private onGraphDataChange() {
            const nodes: Node[] = this.state.selectedNodes.map((node: Node) =>
                this.state.graph.node(node.id)
    
        /**
         * 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, // TODO: Replace layerx/layery non standard properties and fix typing
    
                window: { x: event.clientX, y: event.clientY },
            };
        }
    
        private deselect() {
            this.setState({ selectedNodes: [] });
        }
    
    
        /**
         * Selects a single node, or clears selection if given undefined.
         * @param node Single node to select, or undefined.
         */
    
        private selectNode(node: Node) {
    
        /**
         * Selects multiple nodes, or clears selection if given undefined or empty array.
         * @param nodes Multiple nodes to mark as selected.
         */
        private selectNodes(nodes: Node[]) {
    
        // /**
        //  * Makes sure to always offer a valid format of the selected nodes. Is either undefined or contains at least one valid node. An empty array is never returned.
        //  */
        // private get selectedNodes(): Node[] {
        //     // TODO: Here are a lot of things that should not be possible by design
        //
        //     // Remove undefines
        //     let selectedNodes = this.state.selectedNodes.filter(
        //         (n: Node) => n !== undefined
        //     );
        //
        //     // Remove duplicates
        //     selectedNodes = [...new Set(selectedNodes)];
        //
        //     if (selectedNodes.length > 0) {
        //         return selectedNodes;
        //     }
        //
        //     return undefined;
        // }
    
        private handleNodeClick(node: Node) {
    
            if (this.state.keys["Control"]) {
                // Connect to clicked node as parent while control is pressed
    
                if (this.state.selectedNodes.length == 0) {
    
                    // Have no node connected, so select
                    this.selectNode(node);
    
                } else if (!this.state.selectedNodes.includes(node)) {
    
                    // Already have *other* node/s selected, so connect
    
            } else if (this.state.keys["Shift"]) {
                this.toggleNodeSelection(node);
    
                // By default, simply select node
                this.selectNode(node);
    
            this.forceUpdate(); // TODO: Remove?
    
        private connectSelectionToNode(node: Node) {
    
            if (this.state.selectedNodes.length == 0) {
    
            if (this.state.selectedNodes.length == 1) {
                node.connect(this.state.selectedNodes[0]);
            } else {
                this.state.selectedNodes.forEach((selectedNode: Node) =>
    
                    node.connect(selectedNode)
                );
            }
        }
    
        private toggleNodeSelection(node: Node) {
            // Convert selection to array as basis
    
            let selection = this.state.selectedNodes;
    
    
            // Add/Remove node
            if (selection.includes(node)) {
                // Remove node from selection
                selection = selection.filter((n: Node) => !n.equals(node));
            } else {
                // Add node to selection
                selection.push(node);
            }
            this.selectNodes(selection);
        }
    
    
        private handleNodeCanvasObject(
            node: Node,
            ctx: CanvasRenderingContext2D,
            globalScale: number
        ) {
            // TODO: Refactor
    
    
            if (this.state.selectedNodes.includes(node)) {
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                // Outer circle
    
                ctx.arc(node.x, node.y, 4 * 0.7, 0, 2 * Math.PI, false);
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                ctx.fillStyle = "white";
                ctx.fill();
    
                // Inner circle
                ctx.beginPath();
    
                ctx.arc(node.x, node.y, 4 * 0.3, 0, 2 * Math.PI, false);
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                ctx.fillStyle = node.type.color;
    
                ctx.fill();
            }
    
            // Draw image
            const imageSize = 12;
            if (node.icon !== undefined) {
                const img = new Image();
    
                img.src = node.icon;
    
                    node.x - imageSize / 2,
                    node.y - imageSize / 2,
    
            /**
             * Nothing selected? => Draw all labels
             * If this nodes is considered highlighted => Draw label
             * If this node is a neighbor of a selected node => Draw label
             */
    
            // TODO: Reenable node label rendering
            // const isNodeRelatedToSelection: boolean =
            //     this.state.selectedNodes.length != 0 ||
            //     this.isHighlighted(node) ||
            //     this.selectedNodes.some((selectedNode: Node) =>
            //         selectedNode.neighbors.includes(node)
            //     );
            //
            // if (this.state.visibleLabels && isNodeRelatedToSelection) {
            //     const label = node.name;
            //     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 as any).x - bckgDimensions[0] / 2,
            //         (node as any).y - bckgDimensions[1] / 2 + nodeHeightOffset,
            //         ...bckgDimensions
            //     );
            //
            //     ctx.textAlign = "center";
            //     ctx.textBaseline = "middle";
            //     ctx.fillStyle = "white";
            //     ctx.fillText(
            //         label,
            //         (node as any).x,
            //         (node as any).y + nodeHeightOffset
            //     );
            // }
    
        private handleLinkCanvasObject(
            link: Link,
            ctx: CanvasRenderingContext2D,
            globalScale: number
        ) {
    
            // Links already initialized?
            if (link.source.x === 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.state.selectedNodes.some((node: Node) =>
                    node.links.find(link.equals)
                )
            ) {
    
    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 handleNodeTypeSelect(type: NodeType) {
            const nodesWithType = this.state.graph.nodes.filter((n: Node) =>
                n.type.equals(type)
            );
            this.selectNodes(nodesWithType);
        }
    
    
            if (
                !this.state.selectedNodes ||
                !this.state.selectedNodes.includes(node)
            ) {
    
            // Should run connect logic?
            if (!this.state.connectOnDrag) {
                return;
            }
    
    
            const closest = this.state.graph.getClosestNode(node.x, node.y, 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();
        }
    
    
        /**
         * Processes right-click event on graph elements by deleting them.
         */
    
        private handleElementRightClick(element: GraphElement<unknown, unknown>) {
    
            this.forceUpdate(); // TODO: Necessary?
    
        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.forceUpdate();
        }
    
    
    Maximilian Giller's avatar
    Maximilian Giller committed
        private handleBoxSelect(selectedNodes: Node[]) {
    
            if (selectedNodes !== undefined && selectedNodes.length <= 0) {
                return;
            }
    
    
            this.selectNodes(selectedNodes.concat(this.state.selectedNodes));
    
        render(): React.ReactNode {
            return (
                <div id="ks-editor">
                    <h1>Interface</h1>
    
                    <SpaceSelect onLoadSpace={this.loadSpace} />
    
                    <div id="content">
    
                        <div id="force-graph-renderer" ref={this.graphContainer}>
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                            <SelectLayer
                                allNodes={
                                    this.state.graph ? this.state.graph.nodes : []
                                }
                                screen2GraphCoords={
                                    this.renderer.current
                                        ? this.renderer.current.screen2GraphCoords
                                        : undefined
                                }
                                isEnable={() => this.state.keys["Shift"]}
                                onBoxSelect={this.handleBoxSelect}
                            >
                                {this.state.graph ? (
                                    <ForceGraph2D
                                        ref={this.renderer}
                                        width={this.state.graphWidth}
    
                                        graphData={this.state.graph}
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                                        onNodeClick={this.handleNodeClick}
                                        autoPauseRedraw={false}
                                        cooldownTicks={0}
                                        warmupTicks={this.warmupTicks}
                                        onEngineStop={this.handleEngineStop}
                                        nodeCanvasObject={
                                            this.handleNodeCanvasObject
                                        }
                                        nodeCanvasObjectMode={() => "after"}
                                        linkCanvasObject={
                                            this.handleLinkCanvasObject
                                        }
                                        linkCanvasObjectMode={() => "replace"}
                                        nodeColor={(node: Node) => node.type.color}
                                        onNodeDrag={this.handleNodeDrag}
    
                                        onLinkRightClick={
                                            this.handleElementRightClick
                                        }
                                        onNodeRightClick={
                                            this.handleElementRightClick
                                        }
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                                        onBackgroundClick={(event: any) =>
                                            this.handleBackgroundClick(
                                                event,
                                                this.extractPositions(event)
                                            )
                                        }
                                    />
                                ) : undefined}
                            </SelectLayer>
    
                        <div id="sidepanel">
    
                            <HistoryNavigator
                                spaceId="space"
    
                                onChange={this.onGraphDataChange}
    
                                selectedNodes={this.state.selectedNodes}
    
                                    this.state.graph
                                        ? this.state.graph.objectGroups
                                        : []
    
                                onChange={this.forceUpdate}
    
                            <h3>Node types</h3>
                            <NodeTypesEditor
                                onChange={this.forceUpdate}
                                graph={this.state.graph}
    
                                onSelectAll={this.handleNodeTypeSelect}
    
                            />
                            <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>
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                                <li>
    
                                    SHIFT+Click and drag on background to add nodes
                                    to selection
    
    Maximilian Giller's avatar
    Maximilian Giller committed
                                </li>
    
                                <li>CTRL+Click background to clear selection</li>
    
                                <li>Click node to select and edit</li>
    
                                <li>
                                    SHIFT+Click node to add or remove from selection
                                </li>
                                <li>CTRL+Click another node to connect</li>
                                <li>Right-Click node to delete</li>
                                <li>Right-Click link to delete</li>
    
                                {this.state.connectOnDrag ? (
                                    <li>
                                        Drag node close to other node to connect
                                    </li>
                                ) : (
                                    ""
                                )}
    
                                <li>ESCAPE to clear selection</li>
                            </ul>