From 8504750300dd27d2f59873f2c7a391fd9c298e7c Mon Sep 17 00:00:00 2001 From: Matthias Konitzny <konitzny@ibr.cs.tu-bs.de> Date: Fri, 9 Sep 2022 11:30:01 +0200 Subject: [PATCH] Reworked editor specialized graph (this will break a lot of features) --- src/common/graph.ts | 47 +- src/common/history.ts | 22 +- src/common/node.ts | 2 +- src/editor/graph.ts | 156 ++++++ src/editor/js/components/editor.tsx | 2 +- src/editor/js/components/nodetypeentry.tsx | 2 +- src/editor/js/components/nodetypeseditor.tsx | 2 +- src/editor/js/structures/graph/graph.ts | 492 ------------------- src/editor/js/structures/manageddata.ts | 226 --------- 9 files changed, 216 insertions(+), 735 deletions(-) create mode 100644 src/editor/graph.ts delete mode 100644 src/editor/js/structures/graph/graph.ts delete mode 100644 src/editor/js/structures/manageddata.ts diff --git a/src/common/graph.ts b/src/common/graph.ts index 8399ce6..17ec650 100644 --- a/src/common/graph.ts +++ b/src/common/graph.ts @@ -58,6 +58,7 @@ export class Graph this.reset(); Object.assign(this, data); + this.createDefaultObjectGroupIfNeeded(); this.objectGroups.forEach((group) => this.nameToObjectGroup.set(group.name, group) @@ -68,9 +69,11 @@ export class Graph this.links.forEach((link) => { this.idToLink.set(link.id, link); }); + + this.connectElementsToGraph(); } - private reset() { + protected reset() { this.nodes = []; this.links = []; this.nameToObjectGroup = new Map<string, NodeType>(); @@ -78,6 +81,17 @@ export class Graph this.idToLink = new Map<number, Link>(); } + /** + * Sets the correct graph object for all the graph elements in data. + */ + connectElementsToGraph() { + this.nodes.forEach((n) => (n.graph = this)); + this.links.forEach((l) => { + l.graph = this; + }); + this.objectGroups.forEach((t) => (t.graph = this)); + } + public toJSONSerializableObject(): GraphData { return { nodes: this.nodes.map((node) => node.toJSONSerializableObject()), @@ -108,6 +122,7 @@ export class Graph this.createObjectGroup(group.name, group.color) ); } + this.createDefaultObjectGroupIfNeeded(); data.nodes.forEach((node) => this.createNode(node)); data.links.forEach((link) => this.createLink(link.source, link.target)); @@ -132,6 +147,12 @@ export class Graph return objectGroups; } + private createDefaultObjectGroupIfNeeded() { + if (this.objectGroups.length == 0) { + this.createObjectGroup("Default", "#000000"); + } + } + /** * Updates the graph data structure to contain additional values. * Creates a 'neighbors' and 'links' array for each node object. @@ -213,12 +234,19 @@ export class Graph this.idToLink.set(link.id, link); } - public deleteLink(id: number) { - this.links = this.links.filter((l: Link) => l.id !== id); + public deleteLink(id: number): boolean { + // Remove link from node data structures + const link = this.idToLink.get(id); + link.source.links.filter((l) => l.id != id); + link.target.links.filter((l) => l.id != id); + + // Remove link from graph data structures + this.links = this.links.filter((l: Link) => l.id != id); this.idToLink.delete(id); + return true; } - public deleteNode(id: number) { + public deleteNode(id: number): boolean { const node = this.idToNode.get(id); this.idToNode.delete(id); @@ -226,17 +254,24 @@ export class Graph this.deleteLink(link.id); } this.nodes = this.nodes.filter((n: Node) => n.id !== id); + return true; } - public deleteNodeType(id: string) { + public deleteNodeType(id: string): boolean { + if (this.objectGroups.length <= 1) { + // Do not allow to delete the last node type. + return false; + } + // TODO: Change to id/number const nodeType = this.nameToObjectGroup.get(id); for (const node of this.nodes) { if (node.type.id === nodeType.id) { - node.type = undefined; + node.type = this.objectGroups[0]; } } + return true; } public view( diff --git a/src/common/history.ts b/src/common/history.ts index 966489b..6e18bc9 100644 --- a/src/common/history.ts +++ b/src/common/history.ts @@ -9,11 +9,11 @@ export class History<HistoryDataType> { public maxCheckpoints: number; public currentCheckpoint: number; - private data: SerializableItem<never, HistoryDataType>; + private data: SerializableItem<unknown, HistoryDataType>; private checkpoints: SavePoint<HistoryDataType>[]; constructor( - data: SerializableItem<never, HistoryDataType>, + data: SerializableItem<unknown, HistoryDataType>, maxCheckpoints = 20 ) { this.data = data; @@ -37,23 +37,31 @@ export class History<HistoryDataType> { this.checkpoints.push(checkpoint); } - historyDescription(): Array<string> { + public historyDescription(): Array<string> { return this.checkpoints.map((savepoint) => savepoint.description); } - undo(): SavePoint<HistoryDataType> { - if (this.currentCheckpoint > 0) { + public undo(): SavePoint<HistoryDataType> { + if (this.hasUndoCheckpoints()) { return this.checkpoints[this.currentCheckpoint--]; } else { return this.checkpoints[0]; } } - redo(): SavePoint<HistoryDataType> { - if (this.currentCheckpoint < this.checkpoints.length) { + public redo(): SavePoint<HistoryDataType> { + if (this.hasRedoCheckpoints()) { return this.checkpoints[this.currentCheckpoint++]; } else { return this.checkpoints[this.checkpoints.length - 1]; } } + + public hasUndoCheckpoints(): boolean { + return this.currentCheckpoint > 0; + } + + public hasRedoCheckpoints(): boolean { + return this.currentCheckpoint < this.checkpoints.length; + } } diff --git a/src/common/node.ts b/src/common/node.ts index fa06cf2..c3b8171 100644 --- a/src/common/node.ts +++ b/src/common/node.ts @@ -18,7 +18,7 @@ export interface NodeData extends NodeProperties { * Can be used to store nodes in JSON format. */ id: number; - type?: string; + type: string; } // Based on https://github.com/d3/d3-force#simulation_nodes diff --git a/src/editor/graph.ts b/src/editor/graph.ts new file mode 100644 index 0000000..a40b946 --- /dev/null +++ b/src/editor/graph.ts @@ -0,0 +1,156 @@ +import { Link } from "../common/link"; +import { NodeType } from "../common/nodetype"; +import { Node, NodeData } from "../common/node"; +import * as Common from "../common/graph"; +import { History } from "../common/history"; +import { GraphContent, SimGraphData } from "../common/graph"; + +export class DynamicGraph extends Common.Graph { + private history: History<SimGraphData>; + + // Callbacks + public onChangeCallbacks: { (data: DynamicGraph): void }[]; + + constructor(data?: GraphContent) { + super(data); + this.onChangeCallbacks = []; + this.history = new History<SimGraphData>(this); + } + + /** + * Calls all registered callbacks for the onChange event. + * @private + */ + private triggerOnChange() { + this.onChangeCallbacks.forEach((fn) => fn(this)); + } + + /** + * Triggers change event on data-redo. + */ + protected onRedo() { + if (this.history.hasRedoCheckpoints()) { + const checkpoint = this.history.redo(); + this.fromSerializedObject(checkpoint.data); + this.triggerOnChange(); + } + } + + /** + * Triggers change event on data-undo. + */ + protected onUndo() { + if (this.history.hasUndoCheckpoints()) { + const checkpoint = this.history.undo(); + this.fromSerializedObject(checkpoint.data); + this.triggerOnChange(); + } + } + + public createObjectGroup(name?: string, color?: string): NodeType { + if (name == undefined) { + name = "Unnamed"; + } + if (color == undefined) { + color = "#000000"; + } + const objectGroup = super.createObjectGroup(name, color); + this.triggerOnChange(); + + return objectGroup; + } + + public createNode(data?: NodeData): Node { + if (data == undefined) { + data = { + id: 0, + name: "Undefined", + type: this.objectGroups[0].name, // TODO: Change to id + }; + } + return super.createNode(data); + } + + private delete(id: string | number, fn: (id: string | number) => boolean) { + if (fn(id)) { + this.triggerOnChange(); + return true; + } + return false; + } + + public deleteNodeType(id: string): boolean { + return this.delete(id, super.deleteNodeType); + } + + public deleteNode(id: number): boolean { + return this.delete(id, super.deleteNode); + } + + public deleteLink(id: number): boolean { + return this.delete(id, super.deleteNode); + } + + getLink( + sourceId: number, + targetId: number, + directionSensitive = true + ): Link { + return this.links.find((l) => { + if (l.sourceId === sourceId && l.targetId === targetId) { + return true; + } + + // Check other direction if allowed + return ( + !directionSensitive && + l.sourceId === targetId && + l.targetId === sourceId + ); + }); + } + + public createLink(source: number, target: number): Link { + const link = this.getLink(source, target, false); + if (link !== undefined) { + return link; // Already exists in graph. + } + + return super.createLink(source, target); + } + + /** + * Goes over all nodes and finds the closest node based on distance, that is not the given reference node. + * @param referenceNode Reference node to get closest other node to. + * @returns Closest node and distance. Undefined, if no closest node can be found. + */ + public getClosestNeighbor(referenceNode: Node): { + node: Node; + distance: number; + } { + if (referenceNode == undefined || this.nodes.length < 2) { + return undefined; + } + + // Iterate over all nodes, keep the one with the shortest distance + let closestDistance = Number.MAX_VALUE; + let closestNode: Node = undefined; + this.nodes.forEach((node) => { + if (node.equals(referenceNode)) { + return; // Don't compare to itself + } + + const currentDistance = Math.hypot( + referenceNode.x - node.x, + referenceNode.y - node.y + ); + + if (closestDistance > currentDistance) { + closestDistance = currentDistance; + closestNode = node; + } + }); + + return { node: closestNode, distance: closestDistance }; + } +} diff --git a/src/editor/js/components/editor.tsx b/src/editor/js/components/editor.tsx index 2246956..69b838a 100644 --- a/src/editor/js/components/editor.tsx +++ b/src/editor/js/components/editor.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Graph } from "../structures/graph/graph"; +import { Graph } from "../../graph"; import { loadGraphJson } from "../../../common/datasets"; import { NodeDetails } from "./nodedetails"; import { SpaceSelect } from "./spaceselect"; diff --git a/src/editor/js/components/nodetypeentry.tsx b/src/editor/js/components/nodetypeentry.tsx index 9d36340..fe2f9f0 100644 --- a/src/editor/js/components/nodetypeentry.tsx +++ b/src/editor/js/components/nodetypeentry.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ReactNode } from "react"; -import { Graph } from "../structures/graph/graph"; +import { Graph } from "../../graph"; import { NodeType } from "../../../common/nodetype"; import "./nodetypeentry.css"; diff --git a/src/editor/js/components/nodetypeseditor.tsx b/src/editor/js/components/nodetypeseditor.tsx index 41b60c6..2547294 100644 --- a/src/editor/js/components/nodetypeseditor.tsx +++ b/src/editor/js/components/nodetypeseditor.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ReactNode } from "react"; -import { Graph } from "../structures/graph/graph"; +import { Graph } from "../../graph"; import "./nodetypeseditor.css"; import { NodeTypeEntry } from "./nodetypeentry"; import { NodeType } from "../../../common/nodetype"; diff --git a/src/editor/js/structures/graph/graph.ts b/src/editor/js/structures/graph/graph.ts deleted file mode 100644 index c393bfb..0000000 --- a/src/editor/js/structures/graph/graph.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { Link } from "../../../../common/link"; -import { NodeType } from "../../../../common/nodetype"; -import { Node } from "../../../../common/node"; -import { GraphElement } from "../../../../common/graphelement"; -import * as Common from "../../../../common/graph"; -import { History } from "../../../../common/history"; -import { GraphContent } from "../../../../common/graph"; - -const GRAPH_PARAMS = [...GLOBAL_PARAMS]; -const GRAPH_DATA_PARAMS = ["nodes", "links", "types"]; - -// export type GraphData = { nodes: Node[]; links: Link[]; types: NodeType[] }; - -export class DynamicGraph extends Common.Graph { - private history: History<Common.Graph>; - - private nextNodeId = 0; - private nextLinkId = 0; - private nextTypeId = 0; - - // Callbacks - public onChangeCallbacks: { (data: GraphContent): void }[]; - - constructor(data: GraphContent) { - super(); - this.onChangeCallbacks = []; - - this.connectElementsToGraph(this.data); - this.prepareIds(data); - } - - /** - * Sets the correct graph object for all the graph elements in data. - * @param data Datastructure to connect. - */ - connectElementsToGraph(data: GraphData) { - data.nodes.forEach((n) => (n.graph = this)); - data.links.forEach((l) => { - l.graph = this; - l.source = data.nodes.find((node) => node.id === l.sourceId); - l.target = data.nodes.find((node) => node.id === l.targetId); - }); - data.types.forEach((t) => (t.graph = this)); - } - - /** - * Intuitive getter for links. - * @returns All links associated with the graph. - */ - public get links(): Link[] { - return this.data.links; - } - - /** - * Intuitive getter for nodes. - * @returns All nodes associated with the graph.number - */ - public get nodes(): Node[] { - return this.data.nodes; - } - - /** - * Intuitive getter for node types. - * @returns All node types associated with the graph. - */ - public get types(): NodeType[] { - return this.data.types; - } - - /** - * Determines the highest, used ids for GraphElements in data for later use. - * @param data Data to analyse. - */ - private prepareIds(data: GraphData) { - if (data.links.length > 0) { - this.nextLinkId = this.getHighestId(data.links) + 1; - } - if (data.nodes.length > 0) { - this.nextNodeId = this.getHighestId(data.nodes) + 1; - } - if (data.types.length > 0) { - this.nextTypeId = this.getHighestId(data.types) + 1; - } - } - - /** - * Finds the highest id from a list of graph elements. - * @param elements List of elements containing element with highest id. - * @returns Highest id in list. - */ - private getHighestId(elements: GraphElement[]): number { - let highest = 0; - elements.forEach((element) => { - if (highest < element.id) { - highest = element.id; - } - }); - return highest; - } - - /** - * 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() { - this.triggerOnChange(); - } - - /** - * Triggers change event on data-undo. - */ - protected onUndo() { - this.triggerOnChange(); - } - - protected storableData(data: GraphData): any { - const clean: GraphData = { - nodes: [], - links: [], - types: [], - }; - - clean.links = data.links.map((link) => link.getCleanInstance()); - clean.nodes = data.nodes.map((node) => node.getCleanInstance()); - clean.types = data.types.map((type) => type.getCleanInstance()); - - return clean; - } - - protected restoreData(data: GraphData): any { - const parsedData = Graph.parseData(data); - - this.connectElementsToGraph(parsedData); - - return parsedData; - } - - serialize(): any { - return this.serializeData(this.data); - } - - /** - * Takes a data object and serializes it. - * @param data GraphData object to serialize. - * @returns Serialized data. - */ - private serializeData(data: GraphData): any { - return { - ...this.serializeProperties(GRAPH_PARAMS), - ...this.serializeProperties(GRAPH_DATA_PARAMS, data), - }; - } - - /** - * Adds a pre-created node type to the graph. - * @param nodeType New node type object. - * @returns True, if successful. - */ - public addNodeType(nodeType: NodeType) { - if (this.data.types.includes(nodeType)) { - return true; // Already exists in graph. - } - - // Update id - nodeType.id = this.nextTypeId; - this.nextTypeId += 1; - - // Is valid node? - if (nodeType.name == undefined) { - nodeType.name = "Unnamed"; - } - if (nodeType.color == undefined) { - nodeType.color = "#000000"; - } - - this.data.types.push(nodeType); - - this.triggerOnChange(); - this.storeCurrentData("Added node [" + nodeType + "]"); - - return true; - } - - /** - * Adds a pre-created node to the graph. - * @param node New node object. - * @returns True, if successful. - */ - public addNode(node: Node) { - if (this.data.nodes.includes(node)) { - return true; // Already exists in graph. - } - - // Update id - node.id = this.nextNodeId; - this.nextNodeId += 1; - - // Is valid node? - if (node.name == undefined) { - node.name = "Unnamed"; - } - if (node.type == undefined) { - if (this.types.length > 0) { - // Just give first type in list - node.type = this.types[0]; - } else { - const newType = new NodeType(this); - newType.add(); - node.type = newType; - } - } - - this.data.nodes.push(node); - - this.triggerOnChange(); - this.storeCurrentData("Added node [" + node + "]"); - - return true; - } - - /** - * Deletes a node type from the graph. Only works if at least one type remains after deletion. - * @param nodeType Node type object to remove. - * @returns True, if successful. - */ - public deleteNodeType(nodeType: NodeType): boolean { - // Only allow deletion if at least one other type remains - if (this.types.length <= 1) { - return false; - } - - if (!this.data.types.includes(nodeType)) { - return true; // Doesn't even exist in graph to begin with. - } - - this.data.types = this.data.types.filter( - (n: NodeType) => !n.equals(nodeType) - ); - - try { - // No save points should be created when replacing usages - this.disableStoring(); - - // Replace all usages of this type with the first one in the list - this.nodes.forEach((n: Node) => { - if (n.type.equals(nodeType)) { - n.type = this.types[0]; - } - }); - } finally { - this.enableStoring(); - } - - this.triggerOnChange(); - this.storeCurrentData( - "Deleted type [" + nodeType + "] and replaced usages" - ); - - 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.data.nodes.includes(node)) { - return true; // Doesn't even exist in graph to begin with. - } - - this.data.nodes = this.data.nodes.filter((n: Node) => !n.equals(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(); - this.storeCurrentData( - "Deleted node [" + node + "] and all connected links" - ); - - return true; - } - - getLink( - sourceId: number, - targetId: number, - directionSensitive = true - ): Link { - return this.links.find((l) => { - if (l.sourceId === sourceId && l.targetId === targetId) { - return true; - } - - // Check other direction if allowed - if ( - !directionSensitive && - l.sourceId === targetId && - l.targetId === sourceId - ) { - return true; - } - - return false; - }); - } - - getNode(id: number): Node { - return this.getElementWithId(this.nodes, id); - } - - getType(id: number): NodeType { - return this.getElementWithId(this.types, id); - } - - getElementWithId(elements: GraphElement[], id: number): any { - const numberId = Number(id); - if (isNaN(numberId)) { - return undefined; - } - - return elements.find((e) => e.id === numberId); - } - - /** - * Adds a pre-created link to the graph. - * @param link New link object. - * @returns True, if successful. - */ - public addLink(link: Link): boolean { - if (this.getLink(link.sourceId, link.targetId, false) !== undefined) { - return true; // Already exists in graph. - } - - // Update id - link.id = this.nextLinkId; - this.nextLinkId += 1; - - this.data.links.push(link); - - this.triggerOnChange(); - 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.data.links.includes(link)) { - return true; // Doesn't even exist in graph to begin with. - } - - this.data.links = this.data.links.filter((l: Link) => !l.equals(link)); - - this.triggerOnChange(); - this.storeCurrentData("Deleted link [" + link + "]"); - - return true; - } - - /** - * Calculates the pythagoras distance. - * @param nodeA One node. - * @param nodeB The other node. - * @returns Distance between both nodes. - */ - private nodeDistance(nodeA: Node, nodeB: Node): number { - const a = nodeA as any; - const b = nodeB as any; - - const xDistance = Math.abs(a.x - b.x); - const yDistance = Math.abs(a.y - b.y); - - return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2)); - } - - /** - * Goes over all nodes and finds the closest node based on distance, that is not the given reference node. - * @param referenceNode Reference node to get closest other node to. - * @returns Closest node and distance. Undefined, if no closest node can be found. - */ - public getClosestOtherNode(referenceNode: Node): { - node: Node; - distance: number; - } { - if (referenceNode == undefined || this.nodes.length < 2) { - return undefined; - } - - // Iterate over all nodes, keep the one with the shortest distance - let closestDistance: number = undefined; - let closestNode: Node = undefined; - this.nodes.forEach((node) => { - if (node.equals(referenceNode)) { - return; // Don't compare to itself - } - - const currentDistance = this.nodeDistance(node, referenceNode); - - if ( - closestDistance == undefined || - closestDistance > currentDistance - ) { - closestDistance = currentDistance; - closestNode = node; - } - }); - - return { node: closestNode, distance: closestDistance }; - } - - public static parse(raw: any): Graph { - return new Graph(this.parseData(raw)); - } - - public static parseData(raw: any): GraphData { - const data: GraphData = { - nodes: [], - links: [], - types: [], - }; - - // Parse nodes - if (raw.nodes === undefined) { - throw new Error( - "Invalid graph data format. Could not find any nodes." - ); - } - raw.nodes.forEach((rawNode: any) => { - data.nodes.push(Node.parse(rawNode)); - }); - - // Parse links - if (raw.links === undefined) { - throw new Error( - "Invalid graph data format. Could not find any links." - ); - } - raw.links.forEach((rawLink: any) => { - data.links.push(Link.parse(rawLink)); - // No need to replace node ids with proper node objects, since that should be done in the graph itself. Only have to prepare valid GraphData - }); - - // Collect all node types and give id if none given yet - let typeId: number = undefined; - if (data.nodes.length > 0 && data.nodes[0].type.id === undefined) { - typeId = 0; - } - - // TODO: Remove, when types are directly parsed and not just implicit - data.nodes.forEach((node) => { - const sharedType: NodeType = data.types.find( - (type) => type.name === node.type.name || type.equals(node.type) - ); - - if (sharedType !== undefined) { - node.type = sharedType; // Assign it the stored type, to make sure that it has the same reference as every other node to this type - return; - } - - if (typeId !== undefined) { - node.type.id = typeId; - typeId += 1; - } - - // Doesn't exist in list yet, so add - data.types.push(node.type); - }); - - return data; - } -} diff --git a/src/editor/js/structures/manageddata.ts b/src/editor/js/structures/manageddata.ts deleted file mode 100644 index 1f775d4..0000000 --- a/src/editor/js/structures/manageddata.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { SerializableItem } from "../../../common/serializableitem"; - -const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save"; - -interface SavePoint<DataType> { - id: number; - description: string; - data: DataType; -} - -/** - * Allows objects to have undo/redo functionality in their data and custom save points. - */ -export default class ManagedData<HistoryDataType> { - public data: SerializableItem<never, HistoryDataType>; // Object that will be serialized to history on save. - public history: SavePoint<HistoryDataType>[]; // 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. - public hasNewData: boolean; // True if there is new data to save in the history - - /** - * Sets initial states. - * @param data Initial state of data to be stored. - */ - constructor(data: SerializableItem<never, HistoryDataType>) { - this.data = data; - this.history = []; // Newest state is always at 0 - this.historyPosition = 0; - this.savedHistoryId = 0; - this.storingEnabled = true; - this.hasNewData = false; - } - - /** - * @returns SavePoint of current history position. Gives access to meta data of current data. - */ - public get currentSavePoint(): SavePoint<HistoryDataType> { - return this.history[this.historyPosition]; - } - - /** - * 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() { - // TODO: Remove jQuery! - if (this.hasNewData) { - //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) { - // TODO: Remove any and deprecated window.event check - 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. - } - - /** - * Internally marks the current save point as saved. - */ - private markChangesAsSaved() { - // TODO: Remove if unessesary. Changed accessor from public to private - 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; - } - - /** - * Go to one step back in the stored history, if available. - * @returns True, if successful. - */ - public undo(): boolean { - if (this.step(1)) { - this.updateUnsavedChangesHandler(); - 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(); - return true; - } else { - return false; - } - } - - /** - * Moves current state of data to a given savepoint that is stored in the history. - * @param savePointId Id of desired savepoint. - * @returns True, if successful. - */ - public goToSavePoint(savePointId: number): boolean { - // Iterate overhistory and find position with same savepoint id - for (let i = 0; i < this.history.length; i++) { - if (this.history[i].id === savePointId) { - return this.setHistoryPosition(i); - } - } - return false; // Not found - } - - /** - * 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; - } - - return this.setHistoryPosition(newHistoryPosition); - } - - /** - * Loads a given history index into the current data object and sets historyPosition accordingly. - * @param position Position (Index) of history point to load. - * @returns True, if successful. - */ - private setHistoryPosition(position: number): boolean { - if (position < 0 || position >= this.history.length) { - return false; - } - - this.historyPosition = position; - const savePointData = JSON.parse( - this.history[this.historyPosition].data - ); - this.data = this.restoreData(savePointData); - - 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; - } - - /** - * Restores proper format of data. - * @param data New data to restore. - * @returns Formatted data ready to use. - */ - protected restoreData(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(); - } -} -- GitLab