diff --git a/src/editor/js/components/editor.css b/src/editor/js/components/editor.css index 5869026241c543539cd18b22d38715e7e94726a4..772dd998fe87628a55b0b521aab65e84604da919 100644 --- a/src/editor/js/components/editor.css +++ b/src/editor/js/components/editor.css @@ -8,7 +8,7 @@ div#ks-editor #sidepanel { /* resize: horizontal; */ overflow: auto; min-width: 300px; - max-width: 40%; + max-width: 20%; height: inherit; margin: 0.3rem; margin-right: 0.3rem; diff --git a/src/editor/js/components/editor.tsx b/src/editor/js/components/editor.tsx index cbabc024e9a72807ef78949492e7ae23df4afe9f..ba47e2e180fbe38ac15953294f13c36d06bce83e 100644 --- a/src/editor/js/components/editor.tsx +++ b/src/editor/js/components/editor.tsx @@ -11,38 +11,78 @@ import { GraphElement } from "../structures/graph/graphelement"; import { Link } from "../structures/graph/link"; import { NodeTypesEditor } from "./nodetypeseditor"; import { SpaceManager } from "./spacemanager"; +import { SelectLayer } from "./selectlayer"; +import { NodeType } from "../structures/graph/nodetype"; 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; }; -type clickPosition = { - graph: { x: number; y: number }; - window: { x: number; y: number }; -}; -type positionTranslate = { + +/** + * Coordinate structure used for the force-graph. + */ +type graphCoordinates = { x: number; y: number; - z: number; +}; +/** + * Easy to access format for translated positions of a click event. + */ +type clickPosition = { + 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: any; private graphContainer: any; + + /** + * True, if the graph was the target of the most recent click event. + */ private graphInFocus = false; 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); @@ -57,10 +97,11 @@ 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.handleElementRightClick = this.handleElementRightClick.bind(this); this.selectNode = this.selectNode.bind(this); this.handleResize = this.handleResize.bind(this); + this.handleBoxSelect = this.handleBoxSelect.bind(this); + this.handleNodeTypeSelect = this.handleNodeTypeSelect.bind(this); this.renderer = React.createRef(); this.graphContainer = React.createRef(); @@ -70,12 +111,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 @@ -130,6 +174,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; @@ -140,23 +189,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(); + } + } + + /** + * 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" + ); } } - private handleMouseDown(event: any) { + /** + * 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; @@ -168,6 +256,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({ @@ -195,8 +286,8 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { return; } - // Just deselect if shift key is pressed - if (this.state.keys["Shift"]) { + // Just deselect if control key is pressed + if (this.state.keys["Control"]) { this.selectNode(undefined); return; } @@ -212,19 +303,30 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { newNode.add(this.state.graph); this.forceUpdate(); + + // Select newly created node + if (this.state.keys["Shift"]) { + // Simply add to current selection of shift is pressed + this.toggleNodeSelection(newNode); + } else { + this.selectNode(newNode); + } } /** * 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(); } @@ -234,19 +336,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); - return found !== undefined; + return this.selectedNodes.some((node: Node) => + node.links.find(element.equals) + ); } else { return false; } @@ -267,30 +370,61 @@ 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) { + this.selectNodes([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[]) { this.setState({ - selectedNode: node, + 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 + 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) { this.graphInFocus = true; - if (this.state.keys["Shift"]) { - // Connect two nodes when second select while shift is pressed - if (this.state.selectedNode == undefined) { + if (this.state.keys["Control"]) { + // Connect to clicked node as parent while control 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.connectSelectionToNode(node); } - } else if (this.state.keys["Control"]) { - // Delete node when control is pressed - node.delete(); + } else if (this.state.keys["Shift"]) { + this.toggleNodeSelection(node); } else { // By default, simply select node this.selectNode(node); @@ -298,6 +432,52 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { this.forceUpdate(); } + private connectSelectionToNode(node: Node) { + if (this.selectedNodes === undefined) { + return; + } + + if (this.selectedNodes.length == 1) { + node.connect(this.selectedNodes[0]); + return; + } + + // More than one new link => custom save point handling + try { + this.state.graph.disableStoring(); + this.selectedNodes.forEach((selectedNode: Node) => + node.connect(selectedNode) + ); + } finally { + this.state.graph.enableStoring(); + this.state.graph.storeCurrentData( + "Added " + + this.selectedNodes.length + + " links on [" + + node.toString() + + "]" + ); + } + } + + private toggleNodeSelection(node: Node) { + // Convert selection to array as basis + let selection = this.selectedNodes; + if (selection === undefined) { + selection = []; + } + + // 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: any, globalScale: any) { // add ring just for highlighted nodes if (this.isHighlighted(node)) { @@ -344,10 +524,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.some((selectedNode: Node) => + selectedNode.neighbors.includes(node) + ); if (this.state.visibleLabels && isNodeRelatedToSelection) { const label = node.name; @@ -413,9 +600,19 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { return undefined; } - private handleNodeDrag(node: Node, translate: positionTranslate) { + private handleNodeTypeSelect(type: NodeType) { + const nodesWithType = this.state.graph.nodes.filter((n: Node) => + n.type.equals(type) + ); + this.selectNodes(nodesWithType); + } + + private handleNodeDrag(node: Node) { this.graphInFocus = true; - this.selectNode(node); + + if (!this.selectedNodes || !this.selectedNodes.includes(node)) { + this.selectNode(node); + } // Should run connect logic? if (!this.state.connectOnDrag) { @@ -439,18 +636,14 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { this.forceUpdate(); } - private handleNodeDragEnd(node: Node, translate: positionTranslate) { - return; - } - - private handleLinkClick(link: Link) { + /** + * Processes right-click event on graph elements by deleting them. + */ + private handleElementRightClick(element: GraphElement) { this.graphInFocus = true; - if (this.state.keys["Control"]) { - // Delete link when control is pressed - link.delete(); - this.forceUpdate(); - } + element.delete(); + this.forceUpdate(); } private handleEngineStop() { @@ -465,6 +658,14 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { this.forceUpdate(); } + private handleBoxSelect(selectedNodes: Node[]) { + if (selectedNodes !== undefined && selectedNodes.length <= 0) { + return; + } + + this.selectNodes(selectedNodes.concat(this.selectedNodes)); + } + render(): React.ReactNode { return ( <div id="ks-editor"> @@ -473,35 +674,56 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { <SpaceManager /> <div id="content"> <div id="force-graph-renderer" ref={this.graphContainer}> - {this.state.graph ? ( - <ForceGraph2D - ref={this.renderer} - width={this.state.graphWidth} - 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"} - nodeColor={(node: Node) => node.type.color} - onNodeDrag={this.handleNodeDrag} - onNodeDragEnd={this.handleNodeDragEnd} - onLinkClick={this.handleLinkClick} - onBackgroundClick={(event: any) => - this.handleBackgroundClick( - event, - this.extractPositions(event) - ) - } - /> - ) : undefined} + <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={{ + 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"} + nodeColor={(node: Node) => node.type.color} + onNodeDrag={this.handleNodeDrag} + onLinkRightClick={ + this.handleElementRightClick + } + onNodeRightClick={ + this.handleElementRightClick + } + onBackgroundClick={(event: any) => + this.handleBackgroundClick( + event, + this.extractPositions(event) + ) + } + /> + ) : undefined} + </SelectLayer> </div> <div id="sidepanel"> <HistoryNavigator @@ -511,7 +733,7 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { /> <hr /> <NodeDetails - selectedNode={this.state.selectedNode} + selectedNodes={this.selectedNodes} allTypes={ this.state.graph ? this.state.graph.types : [] } @@ -522,6 +744,7 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { <NodeTypesEditor onChange={this.forceUpdate} graph={this.state.graph} + onSelectAll={this.handleNodeTypeSelect} /> <hr /> <h3>Settings</h3> @@ -565,11 +788,18 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { <hr /> <ul className="instructions"> <li>Click background to create node</li> - <li>SHIFT+Click background to clear selection</li> + <li> + SHIFT+Click and drag on background to add nodes + to selection + </li> + <li>CTRL+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> + <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 @@ -577,7 +807,7 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { ) : ( "" )} - <li>DELETE to delete selected node</li> + <li>DELETE to delete selected nodes</li> <li>ESCAPE to clear selection</li> </ul> </div> diff --git a/src/editor/js/components/nodedetails.css b/src/editor/js/components/nodedetails.css index 20bade5dd9f2508a1e1a968c385971037ee3a7b3..4211813387c9ff1e02816867fe515f50703366bf 100644 --- a/src/editor/js/components/nodedetails.css +++ b/src/editor/js/components/nodedetails.css @@ -12,3 +12,7 @@ div#ks-editor #nodedetails #node-name { font-weight: bold; font-size: large; } + +div#ks-editor #nodedetails .empty-select-option { + display: none; +} diff --git a/src/editor/js/components/nodedetails.tsx b/src/editor/js/components/nodedetails.tsx index 2a619ab3e0a9aede84a9545d178178463787a6df..f6a6ba2270eca997d27a59e53830e930e9a47a19 100644 --- a/src/editor/js/components/nodedetails.tsx +++ b/src/editor/js/components/nodedetails.tsx @@ -5,7 +5,7 @@ import { NodeType } from "../structures/graph/nodetype"; import "./nodedetails.css"; type propTypes = { - selectedNode: Node; + selectedNodes: Node[]; allTypes: NodeType[]; onChange: { (): void }; }; @@ -23,10 +23,54 @@ export class NodeDetails extends React.Component<propTypes> { } private handleNodeTypeChange(event: any) { - this.props.selectedNode.setType(event.target.value); + this.props.selectedNodes.forEach( + (n: Node) => n.setType(event.target.value) // TODO: Later implement new save point handling to collect them all into a big one + ); this.props.onChange(); } + private get referenceNode(): Node { + if ( + this.props.selectedNodes == undefined || + this.props.selectedNodes.length <= 0 + ) { + // Nothing selected + return new Node(); + } else if (this.props.selectedNodes.length === 1) { + // Single node handling + return this.props.selectedNodes[0]; + } else { + // Multiple nodes selected => Create a kind of merged node + const refNode = new Node(); + Object.assign(refNode, this.props.selectedNodes[0]); + + refNode.banner = this.getCollectiveValue((n: Node) => n.banner); + refNode.icon = this.getCollectiveValue((n: Node) => n.icon); + refNode.video = this.getCollectiveValue((n: Node) => n.video); + refNode.type = this.getCollectiveValue((n: Node) => n.type); + + return refNode; + } + } + + /** + * Tries to find a representative value for a specific property over all selected nodes. + * @param propGetter Function that returns the value to test for each node. + * @returns If all nodes have the same value, this value is returned. Otherwise undefined is returned. + */ + getCollectiveValue(propGetter: (n: Node) => any): any { + const sameValue: any = propGetter(this.props.selectedNodes[0]); + + const differentValueFound = this.props.selectedNodes.some( + (n: Node) => propGetter(n) !== sameValue + ); + if (differentValueFound) { + return undefined; + } + + return sameValue; + } + /** * Generic function for handeling a changing text input and applying the new value to the currently selected node. * @param event Change event of text input. @@ -36,22 +80,18 @@ export class NodeDetails extends React.Component<propTypes> { const newValue = event.target.value; // Actual change? - if ((this.props.selectedNode as any)[property] == newValue) { + if ((this.referenceNode as any)[property] == newValue) { return; } - (this.props.selectedNode as any)[property] = newValue; + this.props.selectedNodes.forEach((n: any) => (n[property] = newValue)); this.props.onChange(); // Save change, but debounce, so it doesn't trigger too quickly this.debounce( (property: string) => { - this.props.selectedNode.graph.storeCurrentData( - "Changed " + - property + - " of node [" + - this.props.selectedNode.toString() + - "]" + this.props.selectedNodes[0].graph.storeCurrentData( + "Changed " + property + " of selected nodes" ); this.props.onChange(); }, @@ -82,50 +122,62 @@ export class NodeDetails extends React.Component<propTypes> { } render(): ReactNode { - if (this.props.selectedNode === undefined) { + if ( + this.props.selectedNodes === undefined || + this.props.selectedNodes.length <= 0 + ) { return <p>No Node selected.</p>; } return ( <div id="nodedetails"> - <div> - <label htmlFor="node-name" hidden> - Name - </label> - <input - type="text" - id="node-name" - name="node-name" - placeholder="Enter name" - className="bottom-space" - value={this.props.selectedNode.name} - onChange={(event) => - this.handleTextChange(event, "name") - } - ></input> - </div> - <div> - <label htmlFor="node-description">Description</label> - <br /> - <textarea - id="node-description" - name="node-description" - className="bottom-space" - value={this.props.selectedNode.description} - onChange={(event) => - this.handleTextChange(event, "description") - } - ></textarea> - </div> + {this.props.selectedNodes.length === 1 ? ( + <div> + <label htmlFor="node-name" hidden> + Name + </label> + <input + type="text" + id="node-name" + name="node-name" + placeholder="Enter name" + className="bottom-space" + value={this.referenceNode.name} + onChange={(event) => + this.handleTextChange(event, "name") + } + ></input> + </div> + ) : ( + <h3>{this.props.selectedNodes.length} nodes selected</h3> + )} + + {this.props.selectedNodes.length === 1 ? ( + <div> + <label htmlFor="node-description">Description</label> + <br /> + <textarea + id="node-description" + name="node-description" + className="bottom-space" + value={this.referenceNode.description ?? ""} + onChange={(event) => + this.handleTextChange(event, "description") + } + ></textarea> + </div> + ) : ( + "" + )} <div> <label htmlFor="node-image">Icon Image</label> <br /> - {this.props.selectedNode.icon ? ( + {this.referenceNode.icon ? ( <div> <img id="node-image-preview" className="preview-image" - src={this.props.selectedNode.icon} + src={this.referenceNode.icon} /> <br /> </div> @@ -138,7 +190,7 @@ export class NodeDetails extends React.Component<propTypes> { name="node-image" placeholder="Image URL" className="bottom-space" - value={this.props.selectedNode.icon} + value={this.referenceNode.icon ?? ""} onChange={(event) => this.handleTextChange(event, "icon") } @@ -147,12 +199,12 @@ export class NodeDetails extends React.Component<propTypes> { <div> <label htmlFor="node-detail-image">Banner Image</label> <br /> - {this.props.selectedNode.banner ? ( + {this.referenceNode.banner ? ( <div> <img id="node-image-preview" className="preview-image" - src={this.props.selectedNode.banner} + src={this.referenceNode.banner} /> <br /> </div> @@ -165,7 +217,7 @@ export class NodeDetails extends React.Component<propTypes> { name="node-detail-image" placeholder="Image URL" className="bottom-space" - value={this.props.selectedNode.banner} + value={this.referenceNode.banner ?? ""} onChange={(event) => this.handleTextChange(event, "banner") } @@ -178,9 +230,18 @@ export class NodeDetails extends React.Component<propTypes> { id="node-type" name="node-type" className="bottom-space" - value={this.props.selectedNode.type.id} + value={ + this.referenceNode.type + ? this.referenceNode.type.id + : "" + } onChange={this.handleNodeTypeChange} > + <option + className="empty-select-option" + disabled + selected + ></option> {this.props.allTypes.map((type) => ( <option key={type.id} value={type.id}> {type.name} @@ -196,26 +257,30 @@ export class NodeDetails extends React.Component<propTypes> { placeholder="Video URL" id="node-video" name="node-video" - value={this.props.selectedNode.video} + value={this.referenceNode.video ?? ""} onChange={(event) => this.handleTextChange(event, "video") } ></input> </div> - <div> - <label htmlFor="node-references">References</label>{" "} - <small>One URL per line</small> - <br /> - <textarea - id="node-references" - name="node-references" - className="bottom-space" - value={this.props.selectedNode.references} - onChange={(event) => - this.handleTextChange(event, "references") - } - ></textarea> - </div> + {this.props.selectedNodes.length === 1 ? ( + <div> + <label htmlFor="node-references">References</label>{" "} + <small>One URL per line</small> + <br /> + <textarea + id="node-references" + name="node-references" + className="bottom-space" + value={this.referenceNode.references} + onChange={(event) => + this.handleTextChange(event, "references") + } + ></textarea> + </div> + ) : ( + "" + )} </div> ); } diff --git a/src/editor/js/components/nodetypeentry.tsx b/src/editor/js/components/nodetypeentry.tsx index a5a1cc3b52bd1056d45ba36ad49abc7ef91199ef..fab85cf7e2e71dc6b0185590d99c446d0f35b0d8 100644 --- a/src/editor/js/components/nodetypeentry.tsx +++ b/src/editor/js/components/nodetypeentry.tsx @@ -8,6 +8,7 @@ type propTypes = { graph: Graph; type: NodeType; onChange: { (): void }; + onSelectAll: (type: NodeType) => void; }; type stateTypes = { temporaryColor: string; @@ -82,7 +83,6 @@ export class NodeTypeEntry extends React.Component<propTypes, stateTypes> { return; } - //TODO: Make sure, that this event is not triggered to quickly! (this.props.type as any)[property] = newValue; this.props.onChange(); @@ -139,6 +139,9 @@ export class NodeTypeEntry extends React.Component<propTypes, stateTypes> { } onChange={(event) => this.handleColorChange(event)} /> + <button onClick={() => this.props.onSelectAll(this.props.type)}> + Select nodes + </button> {this.props.graph && this.props.graph.types.length > 1 ? ( <button onClick={this.deleteType}>Delete</button> ) : ( diff --git a/src/editor/js/components/nodetypeseditor.tsx b/src/editor/js/components/nodetypeseditor.tsx index b649a1da777e15a2f10fe45deea5ff9e831763e8..d429dcec671352d43a6d1cb604773e0990e55bf9 100644 --- a/src/editor/js/components/nodetypeseditor.tsx +++ b/src/editor/js/components/nodetypeseditor.tsx @@ -8,6 +8,7 @@ import { NodeType } from "../structures/graph/nodetype"; type propTypes = { graph: Graph; onChange: { (): void }; + onSelectAll: (type: NodeType) => void; }; export class NodeTypesEditor extends React.Component<propTypes> { @@ -35,6 +36,7 @@ export class NodeTypesEditor extends React.Component<propTypes> { key={type.id} type={type} graph={this.props.graph} + onSelectAll={this.props.onSelectAll} /> ))} </ul> diff --git a/src/editor/js/components/selectlayer.css b/src/editor/js/components/selectlayer.css new file mode 100644 index 0000000000000000000000000000000000000000..d30216a4599d7df361ee9bb8e0a4647b038bc876 --- /dev/null +++ b/src/editor/js/components/selectlayer.css @@ -0,0 +1,28 @@ +div#ks-editor #select-layer { + position: relative; +} + +div#ks-editor #box-select { + position: absolute; + z-index: 300000; + border-style: dotted; + border-color: rgba(0, 0, 0, 0); + border-width: 2px; + background-color: rgba(0, 0, 0, 0); + pointer-events: none; + + -webkit-transition-property: border-color, background-color; + -moz-transition-property: border-color, background-color; + -o-transition-property: border-color, background-color; + transition-property: border-color, background-color; + + -webkit-transition-duration: 200ms; + -moz-transition-duration: 200ms; + -o-transition-duration: 200ms; + transition-duration: 200ms; +} + +div#ks-editor #box-select.visible { + border-color: #3e74cc; + background-color: rgba(255, 255, 255, 0.5); +} diff --git a/src/editor/js/components/selectlayer.tsx b/src/editor/js/components/selectlayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94ece3104268917e32d96f9ae4f649f6e4e6955e --- /dev/null +++ b/src/editor/js/components/selectlayer.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { ReactNode } from "react"; +import { Node } from "../structures/graph/node"; +import "./selectlayer.css"; + +type propTypes = { + children: any; + allNodes: Node[]; + isEnable: () => boolean; + screen2GraphCoords: (x: number, y: number) => any; + onBoxSelect: (nodes: Node[]) => void; +}; + +type layerCoordinates = { + x: number; + y: number; +}; + +export class SelectLayer extends React.Component<propTypes> { + private layerContainer: any; + private layerBox: any; + private initialSelectPoint: layerCoordinates = undefined; + + constructor(props: propTypes) { + super(props); + + this.isSelecting = this.isSelecting.bind(this); + this.onBoxSelect = this.onBoxSelect.bind(this); + this.boxSelectOnPointerDown = this.boxSelectOnPointerDown.bind(this); + this.boxSelectOnPointerMove = this.boxSelectOnPointerMove.bind(this); + this.boxSelectOnPointerUp = this.boxSelectOnPointerUp.bind(this); + + this.layerContainer = React.createRef(); + this.layerBox = React.createRef(); + } + + componentDidMount(): void { + this.setupBoxSelect(); + } + + setupBoxSelect() { + // Source: https://github.com/vasturiano/force-graph/issues/151#issuecomment-735850938 + this.layerContainer.current.onpointerdown = this.boxSelectOnPointerDown; + this.layerContainer.current.onpointermove = this.boxSelectOnPointerMove; + this.layerContainer.current.onpointerup = this.boxSelectOnPointerUp; + } + + private isSelecting(): boolean { + if (!this.initialSelectPoint) { + return false; + } + + if (!this.props.isEnable()) { + this.initialSelectPoint = undefined; + this.layerBox.current.className = ""; + return false; + } + + return true; + } + + onBoxSelect(left: number, bottom: number, top: number, right: number) { + // Filter out selected nodes + const hitNodes: Node[] = []; + const tl = this.props.screen2GraphCoords(left, top); + const br = this.props.screen2GraphCoords(right, bottom); + this.props.allNodes.forEach((node: any) => { + if ( + tl.x < node.x && + node.x < br.x && + br.y > node.y && + node.y > tl.y + ) { + // Add node if in box area + hitNodes.push(node); + } + }); + + this.props.onBoxSelect(hitNodes); + } + + boxSelectOnPointerDown(e: any) { + if (!this.props.isEnable()) { + return; + } + + e.preventDefault(); + this.layerBox.current.style.left = e.offsetX.toString() + "px"; + this.layerBox.current.style.top = e.offsetY.toString() + "px"; + this.layerBox.current.style.width = "0px"; + this.layerBox.current.style.height = "0px"; + this.initialSelectPoint = { + x: e.offsetX, + y: e.offsetY, + }; + this.layerBox.current.className = "visible"; + } + + boxSelectOnPointerMove(e: any) { + if (!this.isSelecting()) { + return; + } + + e.preventDefault(); + if (e.offsetX < this.initialSelectPoint.x) { + this.layerBox.current.style.left = e.offsetX.toString() + "px"; + this.layerBox.current.style.width = + (this.initialSelectPoint.x - e.offsetX).toString() + "px"; + } else { + this.layerBox.current.style.left = + this.initialSelectPoint.x.toString() + "px"; + this.layerBox.current.style.width = + (e.offsetX - this.initialSelectPoint.x).toString() + "px"; + } + if (e.offsetY < this.initialSelectPoint.y) { + this.layerBox.current.style.top = e.offsetY.toString() + "px"; + this.layerBox.current.style.height = + (this.initialSelectPoint.y - e.offsetY).toString() + "px"; + } else { + this.layerBox.current.style.top = + this.initialSelectPoint.y.toString() + "px"; + this.layerBox.current.style.height = + (e.offsetY - this.initialSelectPoint.y).toString() + "px"; + } + } + + boxSelectOnPointerUp(e: any) { + if (!this.isSelecting()) { + return; + } + + e.preventDefault(); + let left, bottom, top, right; + if (e.offsetX < this.initialSelectPoint.x) { + left = e.offsetX; + right = this.initialSelectPoint.x; + } else { + left = this.initialSelectPoint.x; + right = e.offsetX; + } + if (e.offsetY < this.initialSelectPoint.y) { + top = e.offsetY; + bottom = this.initialSelectPoint.y; + } else { + top = this.initialSelectPoint.y; + bottom = e.offsetY; + } + this.initialSelectPoint = undefined; + this.layerBox.current.className = ""; + this.onBoxSelect(left, bottom, top, right); + } + + render(): ReactNode { + return ( + <div ref={this.layerContainer} id="select-layer"> + <div ref={this.layerBox} id="box-select"></div> + {this.props.children} + </div> + ); + } +} diff --git a/src/editor/js/structures/graph/node.ts b/src/editor/js/structures/graph/node.ts index dfa16a209cd39f6d93600cae5ba778dacfffe091..e79dbc7b07dcd997064d3a9fe18a8ecf84fbc0a5 100644 --- a/src/editor/js/structures/graph/node.ts +++ b/src/editor/js/structures/graph/node.ts @@ -115,8 +115,8 @@ export class Node extends GraphElement implements Common.Node { } /** - * Connects a given node to itself. Only works if they are in the same graph. - * @param node Other node to connect. + * Connects a given node to itself. Only works if they are in the same graph. This node will be source. + * @param node Other node to connect. Will be target. * @returns The created link, if successful, otherwise undefined. */ public connect(node: Node): Link { diff --git a/src/editor/js/structures/manageddata.ts b/src/editor/js/structures/manageddata.ts index 65c6eeb1d540fa3567ea7c9b0ea1cde518f7f095..702dd86db8884c74145888c5110e988693366516 100644 --- a/src/editor/js/structures/manageddata.ts +++ b/src/editor/js/structures/manageddata.ts @@ -16,7 +16,7 @@ export default class ManagedData extends SerializableItem { public history: SavePoint[]; // All save points of the data. public historyPosition: number; // Currently selected save point in history. Latest always at index 0. private savedHistoryId: number; // Id of save point that is considered saved. - private storingEnabled: boolean; // To internally disable saving of objects on save call. + private storingDisabled: number; // Storing is only enabled if this is 0 (or below). /** * Sets initial states. @@ -28,7 +28,7 @@ export default class ManagedData extends SerializableItem { this.history = []; // Newest state is always at 0 this.historyPosition = 0; this.savedHistoryId = 0; - this.storingEnabled = true; + this.storingDisabled = 0; } /** @@ -88,14 +88,17 @@ export default class ManagedData extends SerializableItem { * Setter to disable storing save points. */ public disableStoring() { - this.storingEnabled = false; + this.storingDisabled += 1; } /** * Setter to enable storing save points. */ public enableStoring() { - this.storingEnabled = true; + this.storingDisabled -= 1; + if (this.storingDisabled < 0) { + this.storingDisabled = 0; + } } /** @@ -217,7 +220,7 @@ export default class ManagedData extends SerializableItem { * @param relevantChanges Indicates major or minor changes. Major changes get a new id to indicate an actual changed state. Should usually be true. */ public storeCurrentData(description: string, relevantChanges = true) { - if (this.storingEnabled === false) { + if (this.storingDisabled) { return; }