diff --git a/src/editor/js/components/editor.tsx b/src/editor/js/components/editor.tsx index aa051e0aeacf7d95a4ef1c0a1c86248bd3572e41..dd33237770d98ae8e4af784518bdc316cbc7d581 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 : [] }