From 0574d98c03431ec50bffe54f6e472c2ef502802e Mon Sep 17 00:00:00 2001
From: Matthias Konitzny <konitzny@ibr.cs.tu-bs.de>
Date: Tue, 13 Sep 2022 17:28:35 +0200
Subject: [PATCH] Reworked node details. There is still a bug which causes the
 force simulation to run on redraw, but at least we are getting rid of the
 forceUpdate() calls now.

---
 src/common/graph/node.ts              |   2 +-
 src/editor/components/nodedetails.tsx | 426 +++++++++++---------------
 src/editor/editor.tsx                 |  30 +-
 3 files changed, 198 insertions(+), 260 deletions(-)

diff --git a/src/common/graph/node.ts b/src/common/graph/node.ts
index 7a1c4e8..78de57b 100644
--- a/src/common/graph/node.ts
+++ b/src/common/graph/node.ts
@@ -3,7 +3,7 @@ import { GraphElement } from "./graphelement";
 import { NodeType } from "./nodetype";
 import { Link } from "./link";
 
-interface NodeProperties {
+export interface NodeProperties {
     name: string;
     description?: string;
     icon?: string;
diff --git a/src/editor/components/nodedetails.tsx b/src/editor/components/nodedetails.tsx
index 9db2e92..f0144d4 100644
--- a/src/editor/components/nodedetails.tsx
+++ b/src/editor/components/nodedetails.tsx
@@ -1,287 +1,205 @@
 import React from "react";
-import { ReactNode } from "react";
 import { Node } from "../../common/graph/node";
 import { NodeType } from "../../common/graph/nodetype";
 import "./nodedetails.css";
+import { NodeDataChangeRequest } from "../editor";
 
-type propTypes = {
+type NodeDetailsProps = {
     selectedNodes: Node[];
-    allTypes: NodeType[];
-    onChange: { (): void };
+    nameToObjectType: Map<string, NodeType>; // TODO: Change to id
+    onNodeDataChange: { (requests: NodeDataChangeRequest[]): void };
 };
 
-export class NodeDetails extends React.Component<propTypes> {
-    debounceTimeout: NodeJS.Timeout;
-    debounceArgs: any[];
-    debounceFunc: any;
-
-    constructor(props: propTypes) {
-        super(props);
-        this.handleNodeTypeChange = this.handleNodeTypeChange.bind(this);
-        this.handleTextChange = this.handleTextChange.bind(this);
-        //this.debounce = this.debounce.bind(this);
+function NodeDetails({
+    selectedNodes,
+    nameToObjectType,
+    onNodeDataChange,
+}: NodeDetailsProps) {
+    if (selectedNodes.length == 0) {
+        return <div id="nodedetails">No node selected.</div>;
     }
 
-    private handleNodeTypeChange(event: any) {
-        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
+    const getCollectiveValue = function <ValueType>(
+        getter: (n: Node) => ValueType,
+        defaultValue: ValueType
+    ) {
+        const referenceValue = getter(selectedNodes[0]);
+        const differentValueFound = selectedNodes.some(
+            (n: Node) => getter(n) !== referenceValue
         );
-        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
+        return differentValueFound ? defaultValue : referenceValue;
+    };
+
+    const referenceData: NodeDataChangeRequest = {
+        id: -1,
+        name: "",
+        description: getCollectiveValue((n) => n.description, undefined),
+        video: getCollectiveValue((n) => n.video, undefined),
+        icon: getCollectiveValue((n) => n.icon, undefined),
+        banner: getCollectiveValue((n) => n.banner, undefined),
+        references: [],
+        type: getCollectiveValue((n) => n.type, undefined),
+    };
+
+    const handleDataChange = function <ValueType>(
+        key: keyof NodeDataChangeRequest,
+        value: ValueType
+    ) {
+        Object.assign(referenceData, { [key]: value });
+        onNodeDataChange(
+            selectedNodes.map((node) => {
+                return { ...referenceData, id: node.id, name: node.name };
+            })
         );
-        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.
-     * @param property Property to give new value.
-     */
-    private handleTextChange(event: any, property: string) {
-        const newValue = event.target.value;
-
-        // Actual change?
-        if ((this.referenceNode as any)[property] == newValue) {
-            return;
-        }
-
-        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.selectedNodes[0].graph.storeCurrentData( TODO: Reimplement
-        //         //     "Changed " + property + " of selected nodes"
-        //         // );
-        //         this.props.onChange();
-        //     },
-        //     500,
-        //     property
-        // );
-    }
+    };
 
-    // debounce(func: any, wait: number, ...args: any[]) {
-    //     // It works, don't question it
-    //     const later = () => {
-    //         this.debounceTimeout = null;
-    //         this.debounceFunc(...this.debounceArgs);
-    //     };
-    //
-    //     clearTimeout(this.debounceTimeout);
-    //     if (
-    //         this.debounceArgs !== undefined &&
-    //         args[0] !== this.debounceArgs[0] &&
-    //         this.debounceFunc !== undefined
-    //     ) {
-    //         this.debounceFunc(...this.debounceArgs);
-    //     }
-    //
-    //     this.debounceArgs = args;
-    //     this.debounceFunc = func;
-    //     this.debounceTimeout = setTimeout(later, wait);
-    // }
-
-    render(): ReactNode {
-        if (
-            this.props.selectedNodes === undefined ||
-            this.props.selectedNodes.length <= 0
-        ) {
-            return <p>No Node selected.</p>;
-        }
-
-        return (
-            <div id="nodedetails">
-                {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>
-                ) : (
-                    ""
-                )}
+    return (
+        <div id="nodedetails">
+            {selectedNodes.length === 1 ? (
                 <div>
-                    <label htmlFor="node-image">Icon Image</label>
-                    <br />
-                    {this.referenceNode.icon ? (
-                        <div>
-                            <img
-                                id="node-image-preview"
-                                className="preview-image"
-                                src={this.referenceNode.icon}
-                            />
-                            <br />
-                        </div>
-                    ) : (
-                        ""
-                    )}
+                    <label htmlFor="node-name" hidden>
+                        Name
+                    </label>
                     <input
                         type="text"
-                        id="node-image"
-                        name="node-image"
-                        placeholder="Image URL"
+                        id="node-name"
+                        name="node-name"
+                        placeholder="Enter name"
                         className="bottom-space"
-                        value={this.referenceNode.icon ?? ""}
+                        value={referenceData.name}
                         onChange={(event) =>
-                            this.handleTextChange(event, "icon")
+                            handleDataChange("name", event.target.value)
                         }
-                    />
+                    ></input>
                 </div>
+            ) : (
+                <h3>{selectedNodes.length} nodes selected</h3>
+            )}
+
+            {selectedNodes.length === 1 ? (
                 <div>
-                    <label htmlFor="node-detail-image">Banner Image</label>
+                    <label htmlFor="node-description">Description</label>
                     <br />
-                    {this.referenceNode.banner ? (
-                        <div>
-                            <img
-                                id="node-image-preview"
-                                className="preview-image"
-                                src={this.referenceNode.banner}
-                            />
-                            <br />
-                        </div>
-                    ) : (
-                        ""
-                    )}
-                    <input
-                        type="text"
-                        id="node-detail-image"
-                        name="node-detail-image"
-                        placeholder="Image URL"
+                    <textarea
+                        id="node-description"
+                        name="node-description"
                         className="bottom-space"
-                        value={this.referenceNode.banner ?? ""}
+                        value={referenceData.description}
                         onChange={(event) =>
-                            this.handleTextChange(event, "banner")
+                            handleDataChange("description", event.target.value)
                         }
-                    />
+                    ></textarea>
                 </div>
+            ) : (
+                ""
+            )}
+            <div>
+                <label htmlFor="node-image">Icon Image</label>
+                <br />
+                {referenceData.icon && (
+                    <div>
+                        <img
+                            id="node-image-preview"
+                            className="preview-image"
+                            src={referenceData.icon}
+                        />
+                        <br />
+                    </div>
+                )}
+                <input
+                    type="text"
+                    id="node-image"
+                    name="node-image"
+                    placeholder="Image URL"
+                    className="bottom-space"
+                    value={referenceData.icon ?? ""}
+                    onChange={(event) =>
+                        handleDataChange("icon", event.target.value)
+                    }
+                />
+            </div>
+            <div>
+                <label htmlFor="node-detail-image">Banner Image</label>
+                <br />
+                {referenceData.banner && (
+                    <div>
+                        <img
+                            id="node-image-preview"
+                            className="preview-image"
+                            src={referenceData.banner}
+                        />
+                        <br />
+                    </div>
+                )}
+                <input
+                    type="text"
+                    id="node-detail-image"
+                    name="node-detail-image"
+                    placeholder="Image URL"
+                    className="bottom-space"
+                    value={referenceData.banner ?? ""}
+                    onChange={(event) =>
+                        handleDataChange("banner", event.target.value)
+                    }
+                />
+            </div>
+            <div>
+                <label htmlFor="node-type">Type</label>
+                <br />
+                <select
+                    id="node-type"
+                    name="node-type"
+                    className="bottom-space"
+                    value={referenceData.type ? referenceData.type.id : ""}
+                    onChange={(event) =>
+                        handleDataChange(
+                            "type",
+                            nameToObjectType.get(event.target.value)
+                        )
+                    }
+                >
+                    <option className="empty-select-option" disabled></option>
+                    {[...nameToObjectType.values()].map((type) => (
+                        <option key={type.id} value={type.id}>
+                            {type.name}
+                        </option>
+                    ))}
+                </select>
+            </div>
+            <div>
+                <label htmlFor="node-video">Video</label>
+                <br />
+                <input
+                    type="text"
+                    placeholder="Video URL"
+                    id="node-video"
+                    name="node-video"
+                    value={referenceData.video ?? ""}
+                    onChange={(event) =>
+                        handleDataChange("video", event.target.value)
+                    }
+                ></input>
+            </div>
+            {selectedNodes.length === 1 ? (
                 <div>
-                    <label htmlFor="node-type">Type</label>
+                    <label htmlFor="node-references">References</label>{" "}
+                    <small>One URL per line</small>
                     <br />
-                    <select
-                        id="node-type"
-                        name="node-type"
+                    <textarea
+                        id="node-references"
+                        name="node-references"
                         className="bottom-space"
-                        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}
-                            </option>
-                        ))}
-                    </select>
-                </div>
-                <div>
-                    <label htmlFor="node-video">Video</label>
-                    <br />
-                    <input
-                        type="text"
-                        placeholder="Video URL"
-                        id="node-video"
-                        name="node-video"
-                        value={this.referenceNode.video ?? ""}
+                        value={referenceData.references}
                         onChange={(event) =>
-                            this.handleTextChange(event, "video")
+                            handleDataChange("references", event.target.value)
                         }
-                    ></input>
+                    ></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>
-        );
-    }
+            ) : (
+                ""
+            )}
+        </div>
+    );
 }
+
+export default NodeDetails;
diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx
index 3fbd284..def7e1f 100644
--- a/src/editor/editor.tsx
+++ b/src/editor/editor.tsx
@@ -1,11 +1,11 @@
 import React from "react";
 import { DynamicGraph } from "./graph";
 import { listAllSpaces, loadGraphJson } from "../common/datasets";
-import { NodeDetails } from "./components/nodedetails";
+import NodeDetails from "./components/nodedetails";
 import SpaceSelect from "./components/spaceselect";
 import "./editor.css";
 import * as Helpers from "../common/helpers";
-import { Node } from "../common/graph/node";
+import { Node, NodeProperties } from "../common/graph/node";
 
 import { NodeTypesEditor } from "./components/nodetypeseditor";
 import { SpaceManager } from "./components/spacemanager";
@@ -18,6 +18,11 @@ import Settings from "./components/settings";
 import HistoryNavigator from "./components/historynavigator";
 import * as Config from "../config";
 
+export interface NodeDataChangeRequest extends NodeProperties {
+    id: number;
+    type: NodeType;
+}
+
 type propTypes = {};
 type stateTypes = {
     /**
@@ -72,6 +77,7 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         this.handleNodeTypeSelect = this.handleNodeTypeSelect.bind(this);
         this.handleBoxSelect = this.handleBoxSelect.bind(this);
         this.selectNodes = this.selectNodes.bind(this);
+        this.handleNodeDataChange = this.handleNodeDataChange.bind(this);
 
         document.addEventListener("keydown", (e) => {
             this.keyPressed(e.key);
@@ -213,7 +219,19 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
         this.selectNodes(nodesWithType);
     }
 
-    private onNodeDataChange() {}
+    private handleNodeDataChange(nodeData: NodeDataChangeRequest[]) {
+        // Make a shallow copy of the graph object to trigger an update over setState
+        const graph = Object.assign(new DynamicGraph(), this.state.graph);
+
+        // Modify node
+        for (const request of nodeData) {
+            const node = graph.node(request.id);
+            Object.assign(node, request);
+        }
+
+        // Push shallow copy to state
+        this.setState({ graph: graph });
+    }
 
     render(): React.ReactNode {
         return (
@@ -270,8 +288,10 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> {
                             <hr />
                             <NodeDetails
                                 selectedNodes={this.state.selectedNodes}
-                                allTypes={this.state.graph.objectGroups}
-                                onChange={this.forceUpdate}
+                                nameToObjectType={
+                                    this.state.graph.nameToObjectGroup
+                                } // TODO: Change to id
+                                onNodeDataChange={this.handleNodeDataChange}
                             />
                             <hr />
                             <NodeTypesEditor
-- 
GitLab