From 40a1c3af5bb9acda8f143661e7a508f7c02e8700 Mon Sep 17 00:00:00 2001
From: Maximilian Giller <m.giller@tu-bs.de>
Date: Mon, 5 Sep 2022 00:24:56 +0200
Subject: [PATCH] First proper internal structure for selecting multiple nodes,
 deletion already working

---
 src/editor/js/components/editor.tsx | 234 ++++++++++++++++++++++------
 1 file changed, 185 insertions(+), 49 deletions(-)

diff --git a/src/editor/js/components/editor.tsx b/src/editor/js/components/editor.tsx
index aa051e0..dd33237 100644
--- a/src/editor/js/components/editor.tsx
+++ b/src/editor/js/components/editor.tsx
@@ -17,38 +17,71 @@ type propTypes = {
     spaceId: string;
 };
 type stateTypes = {
+    /**
+     * Graph structure holding the basic information.
+     */
     graph: Graph;
+
+    /**
+     * Should labels on nodes be rendered, or none at all.
+     */
     visibleLabels: boolean;
+
+    /**
+     * Should feature be enabled, that nodes get connected with a link of dragged close enough to each other?
+     */
     connectOnDrag: boolean;
-    selectedNode: Node;
+
+    /**
+     * 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.
+     */
     keys: { [name: string]: boolean };
+
+    /**
+     * Current width of graph object. Used to specifically adjust and correct the graph size.
+     */
     graphWidth: number;
 };
+
+/**
+ * Coordinate structure used for the force-graph.
+ */
 type graphCoordinates = {
     x: number;
     y: number;
 };
+/**
+ * Easy to access format for translated positions of a click event.
+ */
 type clickPosition = {
     graph: graphCoordinates;
     window: graphCoordinates;
 };
-type positionTranslate = {
-    x: number;
-    y: number;
-    z: number;
-};
 
+/**
+ * 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: any;
     private graphContainer: any;
+
+    /**
+     * True, if the graph was the target of the most recent click event.
+     */
     private graphInFocus = false;
-    private selectBoxStart: graphCoordinates = undefined;
 
     constructor(props: propTypes) {
         super(props);
+
+        // 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);
@@ -63,7 +96,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         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.handleResize = this.handleResize.bind(this);
@@ -77,12 +109,15 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
             graph: undefined,
             visibleLabels: true,
             connectOnDrag: false,
-            selectedNode: undefined,
+            selectedNodes: undefined,
             keys: {},
             graphWidth: 1000,
         };
     }
 
+    /**
+     * Tries to load initial graph after webpage finished loading.
+     */
     componentDidMount() {
         if (this.props.spaceId !== undefined) {
             // Load initial space
@@ -137,6 +172,11 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         return true;
     }
 
+    /**
+     * 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;
 
@@ -147,23 +187,62 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
             keys: keys,
         });
 
-        // Key events
+        this.handleShortcutEvents(key);
+    }
+
+    /**
+     * Triggers actions that correspond with certain shortcuts.
+     *
+     * @param key Newly pressed key.
+     */
+    private handleShortcutEvents(key: string) {
         if (key === "Escape") {
-            // Only delete if 2d-graph is the focused element
             this.selectNode(undefined);
         } else if (
             key === "Delete" &&
-            this.state.selectedNode !== undefined &&
-            this.graphInFocus
+            this.graphInFocus // Only delete if 2d-graph is the focused element
         ) {
-            this.state.selectedNode.delete();
+            this.deleteSelectedNodes();
         }
     }
 
-    private handleMouseDown(event: any) {
+    /**
+     * Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes.
+     */
+    private deleteSelectedNodes() {
+        if (this.selectedNodes === undefined) {
+            return; // Nothing to delete
+        }
+
+        if (this.selectedNodes.length == 1) {
+            this.selectedNodes[0].delete();
+            return;
+        }
+
+        // Delete multiple connected nodes
+        const count: number = this.selectedNodes.length;
+        try {
+            // Disable storing temporarily to create just one big change.
+            this.state.graph.disableStoring();
+            this.selectedNodes.forEach((node: Node) => node.delete());
+        } finally {
+            this.state.graph.enableStoring();
+            this.state.graph.storeCurrentData(
+                "Deleted " + count + " nodes and all connected links"
+            );
+        }
+    }
+
+    /**
+     * Processes page wide mouse down events.
+     */
+    private handleMouseDown() {
         this.graphInFocus = false;
     }
 
+    /**
+     * Processes page wide key up events. Stores corresponding key as not-pressed in state.
+     */
     private handleKeyUp(event: KeyboardEvent) {
         const key: string = event.key;
 
@@ -175,6 +254,9 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         });
     }
 
+    /**
+     * Processes resize window event. Focusses on resizing the graph accordingly.
+     */
     private handleResize() {
         const newGraphWidth = this.graphContainer.current.clientWidth;
         this.setState({
@@ -202,12 +284,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
             return;
         }
 
-        // Just deselect if shift key is pressed
-        if (this.state.keys["Shift"] && !this.selectBoxStart) {
-            this.selectBoxStart = position.graph;
-            return;
-        }
-
         // Just deselect if control key is pressed
         if (this.state.keys["Control"]) {
             this.selectNode(undefined);
@@ -231,13 +307,16 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
      * Propagates the changed state of the graph.
      */
     private onHistoryChange() {
-        if (this.state.selectedNode === undefined) {
+        if (this.selectedNodes === undefined) {
             this.selectNode(undefined);
-        } else {
-            this.selectNode(
-                this.state.graph.getNode(this.state.selectedNode.id)
-            );
+            this.forceUpdate();
+            return;
         }
+
+        const nodes: Node[] = this.selectedNodes.map((node: Node) =>
+            this.state.graph.getNode(node.id)
+        );
+        this.selectNodes(nodes);
         this.forceUpdate();
     }
 
@@ -247,18 +326,20 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
      * @returns True, if element should be highlighted.
      */
     private isHighlighted(element: GraphElement): boolean {
-        if (this.state.selectedNode == undefined || element == undefined) {
+        if (this.selectedNodes == undefined || element == undefined) {
             // Default to false if nothing selected.
             return false;
         }
 
         if (element.node) {
-            // Is node
-            return element.equals(this.state.selectedNode);
+            // Is one of nodes
+            return this.selectedNodes.includes(element as Node);
         } else if (element.link) {
             // Is link
             // Is it one of the adjacent links?
-            const found = this.state.selectedNode.links.find(element.equals);
+            const found = this.selectedNodes.find((node: Node) =>
+                node.links.find(element.equals)
+            );
             return found !== undefined;
         } else {
             return false;
@@ -280,26 +361,71 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         };
     }
 
+    /**
+     * Selects a single node, or clears selection if given undefined.
+     * @param node Single node to select, or undefined.
+     */
     private selectNode(node: Node) {
+        if (node === undefined) {
+            this.setState({
+                selectedNodes: undefined,
+            });
+            return;
+        }
+
         this.setState({
-            selectedNode: node,
+            selectedNodes: [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[]) {
+        if (nodes.length <= 0) {
+            this.setState(undefined);
+            return;
+        }
+
+        this.setState({
+            selectedNodes: nodes,
+        });
+    }
+
+    /**
+     * 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[] {
+        if (this.state.selectedNodes === undefined) {
+            return undefined;
+        }
+
+        // Remove undefines
+        const selectedNodes = this.state.selectedNodes.filter(
+            (n: Node) => n !== undefined
+        );
+
+        if (selectedNodes.length > 0) {
+            return selectedNodes;
+        }
+
+        return undefined;
+    }
+
     private handleNodeClick(node: Node) {
         this.graphInFocus = true;
 
         if (this.state.keys["Shift"]) {
-            // Connect two nodes when second select while shift is pressed
-            if (this.state.selectedNode == undefined) {
+            // Connect to clicked node as parent while shift is pressed
+            if (this.selectedNodes == 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.selectedNodes.includes(node)) {
+                // Already have *other* node/s selected, so connect
+                this.selectedNodes.forEach((selectedNode: Node) =>
+                    node.connect(selectedNode)
+                );
             }
         } else if (this.state.keys["Control"]) {
             // Delete node when control is pressed
@@ -357,10 +483,17 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         }
 
         // Draw label
+        /**
+         * 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
+         */
         const isNodeRelatedToSelection: boolean =
-            this.state.selectedNode === undefined ||
+            this.selectedNodes === undefined ||
             this.isHighlighted(node) ||
-            this.state.selectedNode.neighbors.includes(node);
+            !!this.selectedNodes.find((selectedNode: Node) =>
+                selectedNode.neighbors.includes(node)
+            );
 
         if (this.state.visibleLabels && isNodeRelatedToSelection) {
             const label = node.name;
@@ -426,7 +559,7 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         return undefined;
     }
 
-    private handleNodeDrag(node: Node, translate: positionTranslate) {
+    private handleNodeDrag(node: Node) {
         this.graphInFocus = true;
         this.selectNode(node);
 
@@ -452,10 +585,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         this.forceUpdate();
     }
 
-    private handleNodeDragEnd(node: Node, translate: positionTranslate) {
-        return;
-    }
-
     private handleLinkClick(link: Link) {
         this.graphInFocus = true;
 
@@ -479,7 +608,11 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
     }
 
     private handleBoxSelect(selectedNodes: Node[]) {
-        console.log(selectedNodes);
+        if (selectedNodes !== undefined && selectedNodes.length <= 0) {
+            return;
+        }
+
+        this.selectNodes(selectedNodes);
     }
 
     render(): React.ReactNode {
@@ -525,7 +658,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
                                     linkCanvasObjectMode={() => "replace"}
                                     nodeColor={(node: Node) => node.type.color}
                                     onNodeDrag={this.handleNodeDrag}
-                                    onNodeDragEnd={this.handleNodeDragEnd}
                                     onLinkClick={this.handleLinkClick}
                                     onBackgroundClick={(event: any) =>
                                         this.handleBackgroundClick(
@@ -545,7 +677,11 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
                         />
                         <hr />
                         <NodeDetails
-                            selectedNode={this.state.selectedNode}
+                            selectedNode={
+                                this.selectedNodes
+                                    ? this.selectedNodes[0]
+                                    : undefined
+                            }
                             allTypes={
                                 this.state.graph ? this.state.graph.types : []
                             }
-- 
GitLab