From 2500cabd19d34b9ee1179e76d6918ec65d8b9a37 Mon Sep 17 00:00:00 2001 From: Max <m.giller@tu-bs.de> Date: Wed, 20 Apr 2022 23:31:57 +0200 Subject: [PATCH] Implemented properly typed graph object. --- src/editor/js/graph.js | 337 +----------------- src/editor/js/manageddata.js | 124 ------- src/editor/js/structures/graph/graph.ts | 148 ++++++++ .../js/structures/graph/graphelement.ts | 41 +++ src/editor/js/structures/graph/link.ts | 46 +++ src/editor/js/structures/graph/node.ts | 89 +++++ src/editor/js/structures/graph/nodetype.ts | 12 + .../js/structures/helper/serializableitem.ts | 64 ++++ .../js/structures/helper/serializedurl.ts | 13 + src/editor/js/structures/manageddata.ts | 195 ++++++++++ src/editor/js/structures/space.ts | 15 + 11 files changed, 630 insertions(+), 454 deletions(-) delete mode 100644 src/editor/js/manageddata.js create mode 100644 src/editor/js/structures/graph/graph.ts create mode 100644 src/editor/js/structures/graph/graphelement.ts create mode 100644 src/editor/js/structures/graph/link.ts create mode 100644 src/editor/js/structures/graph/node.ts create mode 100644 src/editor/js/structures/graph/nodetype.ts create mode 100644 src/editor/js/structures/helper/serializableitem.ts create mode 100644 src/editor/js/structures/helper/serializedurl.ts create mode 100644 src/editor/js/structures/manageddata.ts create mode 100644 src/editor/js/structures/space.ts diff --git a/src/editor/js/graph.js b/src/editor/js/graph.js index a925719..68e52e8 100644 --- a/src/editor/js/graph.js +++ b/src/editor/js/graph.js @@ -1,21 +1,12 @@ -import ManagedData from "./manageddata"; +/** +import ManagedData from "./structures/manageddata"; import { PLUGIN_PATH, COLOR_PALETTE } from "../../config"; const LINK_NAME_CONNECTOR = " → "; -export const NODE_LABEL = "name"; -export const NODE_ID = "id"; -export const NODE_TYPE = "type"; -export const NODE_DESCRIPTION = "description"; -export const NODE_IMAGE = "image"; -export const NODE_REFERENCES = "infoLinks"; -export const NODE_VIDEO = "video"; -export const NODE_DETAIL_IMAGE = "infoImage"; -export const LINK_SOURCE = "source"; -export const LINK_TARGET = "target"; -export const LINK_TYPE = "type"; -export const LINK_PARTICLE_COUNT = 4; + + export const GRAPH_NODES = "nodes"; export const GRAPH_LINKS = "links"; @@ -23,30 +14,16 @@ export const GRAPH_LINKS = "links"; export const IMAGE_SIZE = 12; export const IMAGE_SRC = PLUGIN_PATH + "datasets/images/"; -export const LINK_PARAMS = []; -export const NODE_PARAMS = [ - NODE_ID, - NODE_LABEL, - NODE_IMAGE, - NODE_DESCRIPTION, - NODE_REFERENCES, - NODE_VIDEO, - NODE_TYPE, - NODE_DETAIL_IMAGE, -]; -export const LINK_SIM_PARAMS = ["index"]; -export const NODE_SIM_PARAMS = ["index", "x", "y", "vx", "vy", "fx", "fy"]; // Based on https://github.com/d3/d3-force#simulation_nodes export const JSON_CONFIG = PLUGIN_PATH + "datasets/aud1v2.json"; export const STOP_PHYSICS_DELAY = 5000; // ms -export class Graph extends ManagedData { +export class PREVIOUSGraph extends ManagedData { constructor(data) { super(Graph.addIdentifiers(data)); this.calculateNodeTypes(); - this.onChangeCallbacks = []; this.physicsDelay = STOP_PHYSICS_DELAY; this.physicsStopTimeoutId = undefined; } @@ -67,86 +44,6 @@ export class Graph extends ManagedData { }, this.physicsDelay); } - triggerOnChange() { - this.onChangeCallbacks.forEach((fn) => fn(this.data)); - } - - onRedo() { - this.triggerOnChange(); - } - - onUndo() { - this.triggerOnChange(); - } - - storableData(data) { - return this.getCleanData(data, true); - } - - /** - * Based on the function from the 3d-graph code from @JoschaRode - */ - calculateNodeTypes() { - const nodeClasses = []; - - this.data[GRAPH_NODES].forEach((node) => - nodeClasses.push(node[NODE_TYPE]) - ); - - this.nodeTypes = [...new Set(nodeClasses)]; - } - - getNodeColor(node) { - return this.getTypeColor(node[NODE_TYPE]); - } - - getTypeColor(typeClass) { - var classIndex = this.nodeTypes.indexOf(typeClass); - - if (classIndex <= -1) { - return "black"; - } - - return COLOR_PALETTE[classIndex % COLOR_PALETTE.length]; - } - - deleteNode(nodeId) { - // Delete node from nodes - this.data[GRAPH_NODES] = this.data[GRAPH_NODES].filter( - (n) => n[NODE_ID] !== nodeId - ); - - // Delete links with node - this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter( - (l) => - l[LINK_SOURCE][NODE_ID] !== nodeId && - l[LINK_TARGET][NODE_ID] !== nodeId - ); - - this.storeCurrentData("Deleted node with id [" + nodeId + "]"); - } - - deleteNodes(nodeIds) { - if (nodeIds === undefined || nodeIds.length <= 0) { - return; - } - - try { - this.disableStoring(); - - nodeIds.forEach((id) => { - this.deleteNode(id); - }); - } finally { - // Gotta make sure that storing is turned back on again - this.enableStoring(); - } - - this.storeCurrentData( - "Deleted nodes with ids [" + nodeIds.join(",") + "]" - ); - } - stopPhysics() { this.data[GRAPH_NODES].forEach((n) => { n.fx = n.x; @@ -154,54 +51,13 @@ export class Graph extends ManagedData { }); } - static addIdentifiers(data) { - data[GRAPH_NODES].forEach((n) => { - n.node = true; - n.link = false; - }); - data[GRAPH_LINKS].forEach((l) => { - l.node = false; - l.link = true; - }); - - return data; - } - - deleteLink(sourceId, targetId) { - // Only keep links, of one of the nodes is different - this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter( - (l) => - l[LINK_SOURCE][NODE_ID] !== sourceId || - l[LINK_TARGET][NODE_ID] !== targetId - ); - - this.storeCurrentData( - "Deleted link connecting [" + sourceId + "] with [" + targetId + "]" - ); - } - - isLinkOnNode(link, node) { - if (link === undefined || node === undefined) { - return false; - } - - if (link.link !== true || node.node !== true) { - return false; - } - - return ( - link[LINK_SOURCE][NODE_ID] === node[NODE_ID] || - link[LINK_TARGET][NODE_ID] === node[NODE_ID] - ); - } - existsLink(sourceId, targetId) { const links = this.data[GRAPH_LINKS]; for (var i = 0; i < links.length; i++) { var link = links[i]; if ( - link[LINK_SOURCE][NODE_ID] === sourceId && + linkdeleteLink[LINK_SOURCE][NODE_ID] === sourceId && link[LINK_TARGET][NODE_ID] === targetId ) { return true; @@ -211,108 +67,6 @@ export class Graph extends ManagedData { return false; } - changeDetails(selectionDetails) { - if (selectionDetails.node === true) { - this.changeNodeDetails(selectionDetails[NODE_ID], selectionDetails); - } else if (selectionDetails.link === true) { - this.changeLinkDetails( - selectionDetails[LINK_SOURCE][NODE_ID], - selectionDetails[LINK_TARGET][NODE_ID], - selectionDetails - ); - } - } - - changeNodeDetails(nodeId, newDetails) { - var nodes = this.data[GRAPH_NODES]; - for (var i = 0; i < nodes.length; i++) { - // Is relevant node? - if (nodes[i][NODE_ID] !== nodeId) { - continue; // No - } - - // Change details - nodes[i] = Object.assign(nodes[i], newDetails); - - // All done - this.storeCurrentData("Changed node details"); - return; - } - } - - changeLinkDetails(sourceId, targetId, newDetails) { - var links = this.data[GRAPH_LINKS]; - for (var i = 0; i < links.length; i++) { - // Is relevant link? - if ( - links[i][LINK_SOURCE][NODE_ID] !== sourceId || - links[i][LINK_TARGET][NODE_ID] !== targetId - ) { - continue; // No - } - - // Change details - links[i] = Object.assign(links[i], newDetails); - - // All done - this.storeCurrentData("Changed link details"); - return; - } - } - - connectNodes(sourceId, targetIds) { - targetIds.forEach((targetId) => { - if ( - this.existsLink(sourceId, targetId) || - this.existsLink(targetId, sourceId) - ) { - return; - } - - this.addLink(sourceId, targetId); - }); - } - - getCleanData(data = undefined, simulationParameters = false) { - if (data === undefined) { - data = this.data; - } - - var cleanData = {}; - cleanData[GRAPH_LINKS] = []; - cleanData[GRAPH_NODES] = []; - - data[GRAPH_LINKS].forEach((link) => - cleanData[GRAPH_LINKS].push( - this.getCleanLink(link, simulationParameters) - ) - ); - - data[GRAPH_NODES].forEach((node) => - cleanData[GRAPH_NODES].push( - this.getCleanNode(node, simulationParameters) - ) - ); - - return cleanData; - } - - getCleanNode(node, simulationParameters) { - var cleanNode = {}; - - NODE_PARAMS.forEach((param) => { - cleanNode[param] = node[param]; - }); - - if (simulationParameters) { - NODE_SIM_PARAMS.forEach((param) => { - cleanNode[param] = node[param]; - }); - } - - return cleanNode; - } - getCleanLink(link, simulationParameters) { var cleanLink = {}; @@ -329,17 +83,6 @@ export class Graph extends ManagedData { cleanLink[LINK_TARGET] = link[LINK_TARGET]; } - // Other parameters - LINK_PARAMS.forEach((param) => { - cleanLink[param] = link[param]; - }); - - if (simulationParameters) { - LINK_SIM_PARAMS.forEach((param) => { - cleanLink[param] = link[param]; - }); - } - return cleanLink; } @@ -377,73 +120,6 @@ export class Graph extends ManagedData { return result; } - addLink(sourceId, targetId, linkDetails = {}) { - // Copy params - var newLink = linkDetails; - - // Make sure the IDs exist - if ( - sourceId === undefined || - targetId === undefined || - this.existsNodeId(sourceId) === false || - this.existsNodeId(targetId) === false - ) { - return; - } - - // Make sure the link is unique - if (this.existsLink(sourceId, targetId)) { - return; - } - - newLink[LINK_SOURCE] = sourceId; - newLink[LINK_TARGET] = targetId; - - // Basic node properties - newLink.link = true; - newLink.node = false; - - // Add node - this.data[GRAPH_LINKS].push(newLink); - this.triggerOnChange(); - - this.storeCurrentData( - "Added custom link connecting [" + - sourceId + - "] with [" + - targetId + - "]" - ); - - return newLink; - } - - addNode(nodeDetails) { - // Copy params - var newNode = nodeDetails; - - // Make sure the ID is set and unique - if (newNode[NODE_ID] === undefined) { - newNode[NODE_ID] = this.getUnusedNodeId(); - } else if (this.existsNodeId(newNode[NODE_ID])) { - return; - } - - // Basic node properties - newNode.node = true; - newNode.link = false; - - // Add node - this.data[GRAPH_NODES].push(newNode); - this.triggerOnChange(); - - this.storeCurrentData( - "Added custom node with id [" + newNode[NODE_ID] + "]" - ); - - return newNode; - } - static toStr(item) { if (item === undefined) { return "UNDEFINED"; @@ -462,3 +138,4 @@ export class Graph extends ManagedData { } } } +*/ \ No newline at end of file diff --git a/src/editor/js/manageddata.js b/src/editor/js/manageddata.js deleted file mode 100644 index d5c01d4..0000000 --- a/src/editor/js/manageddata.js +++ /dev/null @@ -1,124 +0,0 @@ -import jQuery from "jquery"; - -const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save"; - -export default class ManagedData { - constructor(data) { - this.data = data; - this.history = []; // Newest state is always at 0 - this.historyPosition = 0; - this.savedHistoryId = 0; - this.storingEnabled = true; - - this.storeCurrentData("Initial state", false); - } - - updateUnsavedChangesHandler() { - if (this.hasUnsavedChanges()) { - jQuery(SAVE_BUTTON_ID).removeClass("hidden"); - window.addEventListener("beforeunload", this.handleBeforeUnload); - } else { - jQuery(SAVE_BUTTON_ID).addClass("hidden"); - window.removeEventListener("beforeunload", this.handleBeforeUnload); - } - } - - handleBeforeUnload(e) { - var confirmationMessage = - "If you leave before saving, unsaved changes will be lost."; - - (e || window.event).returnValue = confirmationMessage; //Gecko + IE - return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. - } - - hasUnsavedChanges() { - return this.history[this.historyPosition].id !== this.savedHistoryId; - } - - saveChanges() { - this.savedHistoryId = this.history[this.historyPosition].id; - this.updateUnsavedChangesHandler(); - } - - disableStoring() { - this.storingEnabled = false; - } - - enableStoring() { - this.storingEnabled = true; - } - - onUndo() {} - onRedo() {} - - undo() { - if (this.step(1)) { - this.updateUnsavedChangesHandler(); - this.onUndo(); - return true; - } else { - return false; - } - } - - redo() { - if (this.step(-1)) { - this.updateUnsavedChangesHandler(); - this.onRedo(); - return true; - } else { - return false; - } - } - - step(direction = 1) { - var newHistoryPosition = this.historyPosition + Math.sign(direction); - - if ( - newHistoryPosition >= this.history.length || - newHistoryPosition < 0 - ) { - return false; - } - - this.historyPosition = newHistoryPosition; - this.data = JSON.parse(this.history[this.historyPosition].data); - - return true; - } - - storableData(data) { - return data; - } - - storeCurrentData(description, relevantChanges = true) { - if (this.storingEnabled === false) { - return; - } - - var formattedData = this.storableData(this.data); - - var nextId = 0; - if (this.history.length > 0) { - nextId = this.history[0].id; - - // Keep same as previous id, if nothing relevant changed - // Otherwise, increase by one - if (relevantChanges) { - nextId++; - } - } - - // Forget about the currently stored potential future - this.history.splice(0, this.historyPosition); - this.historyPosition = 0; - - this.history.unshift({ - description: description, - data: JSON.stringify(formattedData), // Creating a deep copy - id: nextId, - }); - - this.updateUnsavedChangesHandler(); - } -} diff --git a/src/editor/js/structures/graph/graph.ts b/src/editor/js/structures/graph/graph.ts new file mode 100644 index 0000000..2b7fc38 --- /dev/null +++ b/src/editor/js/structures/graph/graph.ts @@ -0,0 +1,148 @@ +import ManagedData from "../manageddata"; +import {Link} from "./link"; +import {NodeType} from "./nodetype"; +import {Node} from "./node"; +import {GLOBAL_PARAMS} from "../helper/serializableitem"; + +const GRAPH_PARAMS = ["nodes", "links", "types", ...GLOBAL_PARAMS]; + +export class Graph extends ManagedData { + public nodes: Node[]; + public links: Link[]; + public types: NodeType[]; + + // Callbacks + public onChangeCallbacks: { (data: any): void; } []; + + constructor(data: any) { + super(data); + this.onChangeCallbacks = []; + + // TODO: Parse all node types + } + + /** + * Calls all registered callbacks for the onChange event. + * @private + */ + private triggerOnChange() { + this.onChangeCallbacks.forEach((fn) => fn(this.data)); + } + + /** + * Triggers change event on data-redo. + */ + protected onRedo() { + // TODO: Actually read new data state + this.triggerOnChange(); + } + + /** + * Triggers change event on data-undo. + */ + protected onUndo() { + // TODO: Actually read new data state + this.triggerOnChange(); + } + + protected storableData(data: any): any { + // TODO: Ideally use data parameter + return { + ...this.serialize() + }; + } + + serialize(): any { + return this.serializeProperties(GRAPH_PARAMS); + } + + /** + * Adds a pre-created node to the graph. + * @param node New node object. + * @returns True, if successful. + */ + public addNode(node: Node) { + if (this.nodes.includes(node)) { + return true; // Already exists in graph. + } + + // TODO: Maybe set node id + this.nodes.push(node); + + this.triggerOnChange(); + // TODO: Use toString implementation of node + this.storeCurrentData("Added node [" + node + "]"); + + return true; + } + + /** + * Deletes a node from the graph. + * @param node Node object to remove. + * @returns True, if successful. + */ + public deleteNode(node: Node): boolean { + if (!this.nodes.includes(node)) { + return true; // Doesn't even exist in graph to begin with. + } + + this.nodes.filter((n: Node) => n !== node); + + try { + // No save points should be created when deleting the links + this.disableStoring(); + + // Delete all the links that contain this node + node.links().forEach((l) => { + l.delete(); + }); + } finally { + this.enableStoring(); + } + + this.triggerOnChange(); + // TODO: Use toString implementation of node + this.storeCurrentData("Deleted node [" + node + "] and all connected links"); + + return true; + } + + /** + * Adds a pre-created link to the graph. + * @param link New link object. + * @returns True, if successful. + */ + public addLink(link: Link): boolean { + if (this.links.includes(link)) { + return true; // Already exists in graph. + } + + // TODO: Maybe set link id + this.links.push(link); + + this.triggerOnChange(); + // TODO: Use toString implementation of link + this.storeCurrentData("Added link [" + link + "]"); + + return true; + } + + /** + * Deletes a link from the graph. + * @param link Link object to remove. + * @returns True, if successful. + */ + public deleteLink(link: Link): boolean { + if (!this.links.includes(link)) { + return true; // Doesn't even exist in graph to begin with. + } + + this.links.filter((l: Link) => l !== link); + + this.triggerOnChange(); + // TODO: Use toString implementation of link + this.storeCurrentData("Deleted link [" + link + "]"); + + return true; + } +} \ No newline at end of file diff --git a/src/editor/js/structures/graph/graphelement.ts b/src/editor/js/structures/graph/graphelement.ts new file mode 100644 index 0000000..4072b75 --- /dev/null +++ b/src/editor/js/structures/graph/graphelement.ts @@ -0,0 +1,41 @@ +import {Graph} from "./graph"; +import {SerializableItem} from "../helper/serializableitem"; + +export class GraphElement extends SerializableItem { + protected isNode: boolean; + protected isLink: boolean; + + protected graph: Graph; + + constructor(graph: Graph) { + super(); + this.graph = graph; + this.isNode = false; + this.isLink = false; + } + + /** + * Removes element from its parent graph. + * @returns True, if successful. + */ + public delete(): boolean { + throw new Error("Function \"delete()\" has not been implemented."); + } + + /** + * Adds the element to the given graph. + * @param graph Graph to add element to. + * @returns True, if successful. + */ + public add(graph: Graph = this.graph): boolean { + throw new Error("Function \"add(graph)\" has not been implemented."); + } + + /** + * Needs to be implemented to create a filtered version for storing in the data history. + * @returns Filtered object. + */ + public getCleanInstance(): any { + throw new Error("Function \"getCleanInstance()\" has not been implemented."); + } +} \ No newline at end of file diff --git a/src/editor/js/structures/graph/link.ts b/src/editor/js/structures/graph/link.ts new file mode 100644 index 0000000..bdfd33e --- /dev/null +++ b/src/editor/js/structures/graph/link.ts @@ -0,0 +1,46 @@ +import {GraphElement} from "./graphelement"; +import {Graph} from "./graph"; +import {Node} from "./node"; +import {GLOBAL_PARAMS} from "../helper/serializableitem"; + +const LINK_PARAMS = ["source", "target", ...GLOBAL_PARAMS]; +const LINK_SIM_PARAMS = ["index"]; + +export class Link extends GraphElement { + public source: Node; + public target: Node; + + constructor(graph: Graph) { + super(graph); + this.isLink = true; + } + + public delete() { + return this.graph.deleteLink(this); + } + + public add(graph: Graph = this.graph) { + this.graph = graph; + return this.graph.addLink(this); + } + + /** + * Determines if the given node is part of the link structure. + * @param node Node to check for. + * @returns True, if node is either source or target node of link. + */ + public contains(node: Node): boolean { + return this.source === node || this.target === node; + } + + public serialize(): any { + return this.serializeProperties(LINK_PARAMS); + } + + public getCleanInstance(): any { + return { + ...this.serialize(), + ...this.serializeProperties(LINK_SIM_PARAMS) + }; + } +} \ No newline at end of file diff --git a/src/editor/js/structures/graph/node.ts b/src/editor/js/structures/graph/node.ts new file mode 100644 index 0000000..5976137 --- /dev/null +++ b/src/editor/js/structures/graph/node.ts @@ -0,0 +1,89 @@ +import {Graph} from "./graph"; +import {GraphElement} from "./graphelement" +import {NodeType} from "./nodetype"; +import {SerializedURL} from "../helper/serializedurl"; +import {Link} from "./link"; +import {GLOBAL_PARAMS} from "../helper/serializableitem"; + +const NODE_PARAMS = [ + "label", + "icon", + "description", + "references", + "video", + "type", + "banner", + ...GLOBAL_PARAMS +]; +const NODE_SIM_PARAMS = ["index", "x", "y", "vx", "vy", "fx", "fy"]; // Based on https://github.com/d3/d3-force#simulation_nodes + +export class Node extends GraphElement { + public label: string; + public description: string; + public type: NodeType; + public icon: SerializedURL; + public banner: SerializedURL; + public video: SerializedURL; + public references: SerializedURL[]; + + constructor(graph: Graph) { + super(graph); + this.isNode = true; + } + + public delete() { + return this.graph.deleteNode(this); + } + + public add(graph: Graph = this.graph) { + this.graph = graph; + return this.graph.addNode(this); + } + + /** + * Calculates a list of all connected links to the current node. + * @returns Array containing all connected links. + */ + public links(): Link[] { + const links: Link[] = []; + + this.graph.links.forEach((link) => { + if (link.contains(this)) { + links.push(link); + } + }); + + return links; + } + + /** + * Connects a given node to itself. Only works if they are in the same graph. + * @param node Other node to connect. + * @returns The created link, if successful, otherwise undefined. + */ + public connect(node: Node): Link { + if (this.graph !== node.graph) { + return undefined; + } + + const link = new Link(this.graph); + + link.source = this; + link.target = node; + + if (link.add()) { + return link; + } + } + + public serialize(): any { + return this.serializeProperties(NODE_PARAMS); + } + + public getCleanInstance(): any { + return { + ...this.serialize(), + ...this.serializeProperties(NODE_SIM_PARAMS) + }; + } +} \ No newline at end of file diff --git a/src/editor/js/structures/graph/nodetype.ts b/src/editor/js/structures/graph/nodetype.ts new file mode 100644 index 0000000..c7a1069 --- /dev/null +++ b/src/editor/js/structures/graph/nodetype.ts @@ -0,0 +1,12 @@ +import {GLOBAL_PARAMS, SerializableItem} from "../helper/serializableitem"; + +const NODE_TYPE_PARAMS = ["name", "color", ...GLOBAL_PARAMS]; + +export class NodeType extends SerializableItem { + public name: string; + public color: string; + + serialize(): any { + return this.serializeProperties(NODE_TYPE_PARAMS); + } +} \ No newline at end of file diff --git a/src/editor/js/structures/helper/serializableitem.ts b/src/editor/js/structures/helper/serializableitem.ts new file mode 100644 index 0000000..672c164 --- /dev/null +++ b/src/editor/js/structures/helper/serializableitem.ts @@ -0,0 +1,64 @@ +/** + * Provides the basic interface for unique, serializable objects. + */ +import {array} from "prop-types"; + +export const GLOBAL_PARAMS = ["id"]; + +export class SerializableItem { + public id: string; // Serialized objects need to be unique. + + /** + * Returns the current object in its serialized form. + * @returns The serialized object. + */ + public serialize(): any { + throw new Error("Method 'serialize()' must be implemented."); + } + + /** + * Creates the current object based on raw, serialized data. + * @param raw The serialized data. + */ + public parse(raw: any) { + throw new Error("Method 'parse()' must be implemented."); + } + + /** + * A generic way to create a new object with all the desired parameters of the current one. + * @param params List of parameters to include in the new object. + * @protected + * @returns New object containing all the desired properties. + */ + protected serializeProperties(params: string[]): any { + const serialized: any = {}; + + params.forEach((param) => { + serialized[param] = this.serializeItem((this as any)[param]); + }); + + return serialized; + } + + /** + * Recursively serializes an object. Handles serializable items and lists properly. + * @param value Object to be serialized. + * @private + * @returns Serialized item. + */ + private serializeItem(value: any): any { + if (value instanceof SerializableItem) { + // If is also serializable, use the serialized form + return value.serialize(); + } else if (value instanceof array) { + // If is some kind of list, convert the objects in the list recursively + const serializedList: any = []; + (value as []).forEach((item) => { + serializedList.push(this.serializeItem(item)); + }) + return serializedList; + } else { + return value; + } + } +} diff --git a/src/editor/js/structures/helper/serializedurl.ts b/src/editor/js/structures/helper/serializedurl.ts new file mode 100644 index 0000000..7a56676 --- /dev/null +++ b/src/editor/js/structures/helper/serializedurl.ts @@ -0,0 +1,13 @@ +import {GLOBAL_PARAMS, SerializableItem} from "./serializableitem"; + +const URL_PARAMS = ["link", ...GLOBAL_PARAMS]; + +export class SerializedURL extends SerializableItem { + public link: string; // The full url + + // TODO: URL validator + + serialize(): any { + return this.serializeProperties(URL_PARAMS); + } +} \ No newline at end of file diff --git a/src/editor/js/structures/manageddata.ts b/src/editor/js/structures/manageddata.ts new file mode 100644 index 0000000..61d4c18 --- /dev/null +++ b/src/editor/js/structures/manageddata.ts @@ -0,0 +1,195 @@ +import {SerializableItem} from "./helper/serializableitem"; +import jQuery from "jquery"; + +const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save"; + +/** + * Allows objects to have undo/redo functionality in their data and custom save points. + */ +export default class ManagedData extends SerializableItem { + public data: any; // The data to be stored in a history. + public history: any[]; // 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. + + /** + * Sets initial states. + * @param data Initial state of data to be stored. + */ + constructor(data: any) { + super(); + this.data = data; + this.history = []; // Newest state is always at 0 + this.historyPosition = 0; + this.savedHistoryId = 0; + this.storingEnabled = true; + + this.storeCurrentData("Initial state", false); + } + + /** + * If the data has unsaved changes, this will subscribe to the tab-closing event to warn about losing unsaved changes before closing. + * @private + */ + private updateUnsavedChangesHandler() { + if (this.hasUnsavedChanges()) { + jQuery(SAVE_BUTTON_ID).removeClass("hidden"); + window.addEventListener("beforeunload", this.handleBeforeUnload); + } else { + jQuery(SAVE_BUTTON_ID).addClass("hidden"); + window.removeEventListener("beforeunload", this.handleBeforeUnload); + } + } + + /** + * Called on the tab-closing event to trigger a warning, to avoid losing unsaved changes. + * @param e Event. + * @private + */ + private handleBeforeUnload(e: any) { + const confirmationMessage = + "If you leave before saving, unsaved changes will be lost."; + + (e || window.event).returnValue = confirmationMessage; //Gecko + IE + return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. + } + + /** + * Returns true, if data has unsaved changes. + */ + public hasUnsavedChanges(): boolean { + return this.history[this.historyPosition].id !== this.savedHistoryId; + } + + /** + * Internally marks the current save point as saved. + */ + public markChangesAsSaved() { + this.savedHistoryId = this.history[this.historyPosition].id; + this.updateUnsavedChangesHandler(); + } + + /** + * Setter to disable storing save points. + */ + public disableStoring() { + this.storingEnabled = false; + } + + /** + * Setter to enable storing save points. + */ + public enableStoring() { + this.storingEnabled = true; + } + + /** + * Event triggered after undo. + */ + protected onUndo() { + // No base implementation. + } + + /** + * Event triggered after redo. + */ + protected onRedo() { + // No base implementation. + } + + /** + * Go to one step back in the stored history, if available. + * @returns True, if successful. + */ + public undo(): boolean { + if (this.step(1)) { + this.updateUnsavedChangesHandler(); + this.onUndo(); + return true; + } else { + return false; + } + } + + /** + * Go one step forward in the stored history, if available. + * @returns True, if successful. + */ + public redo(): boolean { + if (this.step(-1)) { + this.updateUnsavedChangesHandler(); + this.onRedo(); + return true; + } else { + return false; + } + } + + /** + * Moves the history pointer to the desired position and adjusts the data object. + * @param direction How many steps to take in the history. Positive for going back in time, negative for going forward. + * @returns True, if successful. + * @private + */ + private step(direction = 1): boolean { + const newHistoryPosition = this.historyPosition + Math.sign(direction); + + if ( + newHistoryPosition >= this.history.length || + newHistoryPosition < 0 + ) { + return false; + } + + this.historyPosition = newHistoryPosition; + this.data = JSON.parse(this.history[this.historyPosition].data); + + return true; + } + + /** + * Formats the data to the desired stored format. + * @param data The raw data. + * @returns The formatted, cleaned up data to be stored. + */ + protected storableData(data: any): any { + return data; + } + + /** + * Creates a save point. + * @param description Description of the current save point. Could describe the difference to the previous save point. + * @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) { + return; + } + + const formattedData = this.storableData(this.data); + + let nextId = 0; + if (this.history.length > 0) { + nextId = this.history[0].id; + + // Keep same as previous id, if nothing relevant changed + // Otherwise, increase by one + if (relevantChanges) { + nextId++; + } + } + + // Forget about the currently stored potential future + this.history.splice(0, this.historyPosition); + this.historyPosition = 0; + + this.history.unshift({ + description: description, + data: JSON.stringify(formattedData), // Creating a deep copy + id: nextId, + }); + + this.updateUnsavedChangesHandler(); + } +} diff --git a/src/editor/js/structures/space.ts b/src/editor/js/structures/space.ts new file mode 100644 index 0000000..470e13d --- /dev/null +++ b/src/editor/js/structures/space.ts @@ -0,0 +1,15 @@ +import {GLOBAL_PARAMS, SerializableItem} from "./helper/serializableitem"; +import {Graph} from "./graph/graph"; + +const SPACE_PARAMS = ["name", "description", "graph", ...GLOBAL_PARAMS]; + +export class Space extends SerializableItem { + public name: string; + public description: string; + + public graph: Graph; + + serialize(): any { + return this.serializeProperties(SPACE_PARAMS); + } +} \ No newline at end of file -- GitLab