import { SerializableItem } from "./helper/serializableitem"; import jQuery from "jquery"; const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save"; type SavePoint = { description: string; data: string; id: number; }; /** * 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: 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. /** * 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; } /** * @returns SavePoint of current history position. Gives access to meta data of current data. */ public get currentSavePoint(): SavePoint { 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() { 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 { if (this.history[this.historyPosition] === undefined) { return this.data !== undefined; } 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 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(); } }