Skip to content
Snippets Groups Projects
manageddata.ts 7.35 KiB
Newer Older
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();
    }
}