Source: svgcanvas/history.js

/* eslint-disable no-console */
/**
 * For command history tracking and undo functionality.
 * @module history
 * @license MIT
 * @copyright 2010 Jeff Schiller
 */

import { getHref, setHref, getRotationAngle, isNullish } from './utilities.js';

/**
* Group: Undo/Redo history management.
*/
export const HistoryEventTypes = {
  BEFORE_APPLY: 'before_apply',
  AFTER_APPLY: 'after_apply',
  BEFORE_UNAPPLY: 'before_unapply',
  AFTER_UNAPPLY: 'after_unapply'
};

/**
* Base class for commands.
*/
export class Command {
  /**
  * @returns {string}
  */
  getText () {
    return this.text;
  }
  /**
   * @param {module:history.HistoryEventHandler} handler
   * @param {callback} applyFunction
   * @returns {void}
  */
  apply (handler, applyFunction) {
    handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_APPLY, this);
    applyFunction(handler);
    handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_APPLY, this);
  }

  /**
   * @param {module:history.HistoryEventHandler} handler
   * @param {callback} unapplyFunction
   * @returns {void}
  */
  unapply (handler, unapplyFunction) {
    handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_UNAPPLY, this);
    unapplyFunction();
    handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_UNAPPLY, this);
  }

  /**
   * @returns {Element[]} Array with element associated with this command
   * This function needs to be surcharged if multiple elements are returned.
  */
  elements () {
    return [ this.elem ];
  }

  /**
    * @returns {string} String with element associated with this command
  */
  type () {
    return this.constructor.name;
  }
}

// Todo: Figure out why the interface members aren't showing
//   up (with or without modules applied), despite our apparently following
//   http://usejsdoc.org/tags-interface.html#virtual-comments

/**
 * An interface that all command objects must implement.
 * @interface module:history.HistoryCommand
*/
/**
 * Applies.
 *
 * @function module:history.HistoryCommand#apply
 * @param {module:history.HistoryEventHandler} handler
 * @fires module:history~Command#event:history
 * @returns {void|true}
 */
/**
 *
 * Unapplies.
 * @function module:history.HistoryCommand#unapply
 * @param {module:history.HistoryEventHandler} handler
 * @fires module:history~Command#event:history
 * @returns {void|true}
 */
/**
 * Returns the elements.
 * @function module:history.HistoryCommand#elements
 * @returns {Element[]}
 */
/**
 * Gets the text.
 * @function module:history.HistoryCommand#getText
 * @returns {string}
 */
/**
 * Gives the type.
 * @function module:history.HistoryCommand.type
 * @returns {string}
 */

/**
 * @event module:history~Command#event:history
 * @type {module:history.HistoryCommand}
 */

/**
 * An interface for objects that will handle history events.
 * @interface module:history.HistoryEventHandler
 */
/**
 *
 * @function module:history.HistoryEventHandler#handleHistoryEvent
 * @param {string} eventType One of the HistoryEvent types
 * @param {module:history~Command#event:history} command
 * @listens module:history~Command#event:history
 * @returns {void}
 *
 */

/**
 * History command for an element that had its DOM position changed.
 * @implements {module:history.HistoryCommand}
*/
export class MoveElementCommand extends Command {
  /**
  * @param {Element} elem - The DOM element that was moved
  * @param {Element} oldNextSibling - The element's next sibling before it was moved
  * @param {Element} oldParent - The element's parent before it was moved
  * @param {string} [text] - An optional string visible to user related to this change
  */
  constructor (elem, oldNextSibling, oldParent, text) {
    super();
    this.elem = elem;
    this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName);
    this.oldNextSibling = oldNextSibling;
    this.oldParent = oldParent;
    this.newNextSibling = elem.nextSibling;
    this.newParent = elem.parentNode;
  }

  /**
   * Re-positions the element.
   * @param {module:history.HistoryEventHandler} handler
   * @fires module:history~Command#event:history
   * @returns {void}
  */
  apply (handler) {
    super.apply(handler, () => {
      this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling);
    });
  }

  /**
   * Positions the element back to its original location.
   * @param {module:history.HistoryEventHandler} handler
   * @fires module:history~Command#event:history
   * @returns {void}
  */
  unapply (handler) {
    super.unapply(handler, () => {
      this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling);
    });
  }
}

/**
* History command for an element that was added to the DOM.
* @implements {module:history.HistoryCommand}
*/
export class InsertElementCommand extends Command {
  /**
   * @param {Element} elem - The newly added DOM element
   * @param {string} text - An optional string visible to user related to this change
  */
  constructor (elem, text) {
    super();
    this.elem = elem;
    this.text = text || ('Create ' + elem.tagName);
    this.parent = elem.parentNode;
    this.nextSibling = this.elem.nextSibling;
  }

  /**
  * Re-inserts the new element.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  apply (handler) {
    super.apply(handler, () => {
      this.elem = this.parent.insertBefore(this.elem, this.nextSibling);
    });
  }

  /**
  * Removes the element.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  unapply (handler) {
    super.unapply(handler, () => {
      this.parent = this.elem.parentNode;
      this.elem.remove();
    });
  }
}


/**
* History command for an element removed from the DOM.
* @implements {module:history.HistoryCommand}
*/
export class RemoveElementCommand extends Command {
  /**
  * @param {Element} elem - The removed DOM element
  * @param {Node} oldNextSibling - The DOM element's nextSibling when it was in the DOM
  * @param {Element} oldParent - The DOM element's parent
  * @param {string} [text] - An optional string visible to user related to this change
  */
  constructor (elem, oldNextSibling, oldParent, text) {
    super();
    this.elem = elem;
    this.text = text || ('Delete ' + elem.tagName);
    this.nextSibling = oldNextSibling;
    this.parent = oldParent;
  }

  /**
  * Re-removes the new element.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  apply (handler) {
    super.apply(handler, () => {
      this.parent = this.elem.parentNode;
      this.elem.remove();
    });
  }

  /**
  * Re-adds the new element.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  unapply (handler) {
    super.unapply(handler, () => {
      if (isNullish(this.nextSibling) && window.console) {
        console.error('Reference element was lost');
      }
      this.parent.insertBefore(this.elem, this.nextSibling); // Don't use `before` or `prepend` as `this.nextSibling` may be `null`
    });
  }
}

/**
* @typedef {"#text"|"#href"|string} module:history.CommandAttributeName
*/
/**
* @typedef {PlainObject<module:history.CommandAttributeName, string>} module:history.CommandAttributes
*/

/**
* History command to make a change to an element.
* Usually an attribute change, but can also be textcontent.
* @implements {module:history.HistoryCommand}
*/
export class ChangeElementCommand extends Command {
  /**
  * @param {Element} elem - The DOM element that was changed
  * @param {module:history.CommandAttributes} attrs - Attributes to be changed with the values they had *before* the change
  * @param {string} text - An optional string visible to user related to this change
   */
  constructor (elem, attrs, text) {
    super();
    this.elem = elem;
    this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName);
    this.newValues = {};
    this.oldValues = attrs;
    for (const attr in attrs) {
      if (attr === '#text') {
        this.newValues[attr] = (elem) ? elem.textContent : '';
      } else if (attr === '#href') {
        this.newValues[attr] = getHref(elem);
      } else {
        this.newValues[attr] = elem.getAttribute(attr);
      }
    }
  }

  /**
  * Performs the stored change action.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  apply (handler) {
    super.apply(handler, () => {
      let bChangedTransform = false;
      Object.entries(this.newValues).forEach(([ attr, value ]) => {
        if (value) {
          if (attr === '#text') {
            this.elem.textContent = value;
          } else if (attr === '#href') {
            setHref(this.elem, value);
          } else {
            this.elem.setAttribute(attr, value);
          }
        } else if (attr === '#text') {
          this.elem.textContent = '';
        } else {
          this.elem.setAttribute(attr, '');
          this.elem.removeAttribute(attr);
        }

        if (attr === 'transform') { bChangedTransform = true; }
      });

      // relocate rotational transform, if necessary
      if (!bChangedTransform) {
        const angle = getRotationAngle(this.elem);
        if (angle) {
          const bbox = this.elem.getBBox();
          const cx = bbox.x + bbox.width / 2;
          const cy = bbox.y + bbox.height / 2;
          const rotate = [ 'rotate(', angle, ' ', cx, ',', cy, ')' ].join('');
          if (rotate !== this.elem.getAttribute('transform')) {
            this.elem.setAttribute('transform', rotate);
          }
        }
      }
    });
  }

  /**
  * Reverses the stored change action.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  unapply (handler) {
    super.unapply(handler, () => {
      let bChangedTransform = false;
      Object.entries(this.oldValues).forEach(([ attr, value ]) => {
        if (value) {
          if (attr === '#text') {
            this.elem.textContent = value;
          } else if (attr === '#href') {
            setHref(this.elem, value);
          } else {
            this.elem.setAttribute(attr, value);
          }
        } else if (attr === '#text') {
          this.elem.textContent = '';
        } else {
          this.elem.removeAttribute(attr);
        }
        if (attr === 'transform') { bChangedTransform = true; }
      });
      // relocate rotational transform, if necessary
      if (!bChangedTransform) {
        const angle = getRotationAngle(this.elem);
        if (angle) {
          const bbox = this.elem.getBBox();
          const cx = bbox.x + bbox.width / 2;
          const cy = bbox.y + bbox.height / 2;
          const rotate = [ 'rotate(', angle, ' ', cx, ',', cy, ')' ].join('');
          if (rotate !== this.elem.getAttribute('transform')) {
            this.elem.setAttribute('transform', rotate);
          }
        }
      }
    });
  }
}

// TODO: create a 'typing' command object that tracks changes in text
// if a new Typing command is created and the top command on the stack is also a Typing
// and they both affect the same element, then collapse the two commands into one

/**
* History command that can contain/execute multiple other commands.
* @implements {module:history.HistoryCommand}
*/
export class BatchCommand extends Command {
  /**
  * @param {string} [text] - An optional string visible to user related to this change
  */
  constructor (text) {
    super();
    this.text = text || 'Batch Command';
    this.stack = [];
  }

  /**
  * Runs "apply" on all subcommands.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  apply (handler) {
    super.apply(handler, () => {
      this.stack.forEach((stackItem) => {
        console.assert(stackItem, 'stack item should not be null');
        stackItem && stackItem.apply(handler);
      });
    });
  }

  /**
  * Runs "unapply" on all subcommands.
  * @param {module:history.HistoryEventHandler} handler
  * @fires module:history~Command#event:history
  * @returns {void}
  */
  unapply (handler) {
    super.unapply(handler, () => {
      this.stack.reverse().forEach((stackItem) => {
        console.assert(stackItem, 'stack item should not be null');
        stackItem && stackItem.unapply(handler);
      });
    });
  }

  /**
  * Iterate through all our subcommands.
  * @returns {Element[]} All the elements we are changing
  */
  elements () {
    const elems = [];
    let cmd = this.stack.length;
    while (cmd--) {
      if (!this.stack[cmd]) continue;
      const thisElems = this.stack[cmd].elements();
      let elem = thisElems.length;
      while (elem--) {
        if (!elems.includes(thisElems[elem])) { elems.push(thisElems[elem]); }
      }
    }
    return elems;
  }

  /**
  * Adds a given command to the history stack.
  * @param {Command} cmd - The undo command object to add
  * @returns {void}
  */
  addSubCommand (cmd) {
    console.assert(cmd !== null, 'cmd should not be null');
    this.stack.push(cmd);
  }

  /**
  * @returns {boolean} Indicates whether or not the batch command is empty
  */
  isEmpty () {
    return !this.stack.length;
  }
}

/**
*
*/
export class UndoManager {
  /**
  * @param {module:history.HistoryEventHandler} historyEventHandler
  */
  constructor (historyEventHandler) {
    this.handler_ = historyEventHandler || null;
    this.undoStackPointer = 0;
    this.undoStack = [];

    // this is the stack that stores the original values, the elements and
    // the attribute name for begin/finish
    this.undoChangeStackPointer = -1;
    this.undoableChangeStack = [];
  }

  /**
  * Resets the undo stack, effectively clearing the undo/redo history.
  * @returns {void}
  */
  resetUndoStack () {
    this.undoStack = [];
    this.undoStackPointer = 0;
  }

  /**
  * @returns {Integer} Current size of the undo history stack
  */
  getUndoStackSize () {
    return this.undoStackPointer;
  }

  /**
  * @returns {Integer} Current size of the redo history stack
  */
  getRedoStackSize () {
    return this.undoStack.length - this.undoStackPointer;
  }

  /**
  * @returns {string} String associated with the next undo command
  */
  getNextUndoCommandText () {
    return this.undoStackPointer > 0 ? this.undoStack[this.undoStackPointer - 1].getText() : '';
  }

  /**
  * @returns {string} String associated with the next redo command
  */
  getNextRedoCommandText () {
    return this.undoStackPointer < this.undoStack.length ? this.undoStack[this.undoStackPointer].getText() : '';
  }

  /**
  * Performs an undo step.
  * @returns {void}
  */
  undo () {
    if (this.undoStackPointer > 0) {
      const cmd = this.undoStack[--this.undoStackPointer];
      cmd.unapply(this.handler_);
    }
  }

  /**
  * Performs a redo step.
  * @returns {void}
  */
  redo () {
    if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
      const cmd = this.undoStack[this.undoStackPointer++];
      cmd.apply(this.handler_);
    }
  }

  /**
  * Adds a command object to the undo history stack.
  * @param {Command} cmd - The command object to add
  * @returns {void}
  */
  addCommandToHistory (cmd) {
    // TODO: we MUST compress consecutive text changes to the same element
    // (right now each keystroke is saved as a separate command that includes the
    // entire text contents of the text element)
    // TODO: consider limiting the history that we store here (need to do some slicing)

    // if our stack pointer is not at the end, then we have to remove
    // all commands after the pointer and insert the new command
    if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
      this.undoStack = this.undoStack.splice(0, this.undoStackPointer);
    }
    this.undoStack.push(cmd);
    this.undoStackPointer = this.undoStack.length;
  }

  /**
  * This function tells the canvas to remember the old values of the
  * `attrName` attribute for each element sent in.  The elements and values
  * are stored on a stack, so the next call to `finishUndoableChange()` will
  * pop the elements and old values off the stack, gets the current values
  * from the DOM and uses all of these to construct the undo-able command.
  * @param {string} attrName - The name of the attribute being changed
  * @param {Element[]} elems - Array of DOM elements being changed
  * @returns {void}
  */
  beginUndoableChange (attrName, elems) {
    const p = ++this.undoChangeStackPointer;
    let i = elems.length;
    const oldValues = new Array(i); const elements = new Array(i);
    while (i--) {
      const elem = elems[i];
      if (isNullish(elem)) { continue; }
      elements[i] = elem;
      oldValues[i] = elem.getAttribute(attrName);
    }
    this.undoableChangeStack[p] = {
      attrName,
      oldValues,
      elements
    };
  }

  /**
  * This function returns a `BatchCommand` object which summarizes the
  * change since `beginUndoableChange` was called.  The command can then
  * be added to the command history.
  * @returns {BatchCommand} Batch command object with resulting changes
  */
  finishUndoableChange () {
    const p = this.undoChangeStackPointer--;
    const changeset = this.undoableChangeStack[p];
    const { attrName } = changeset;
    const batchCmd = new BatchCommand('Change ' + attrName);
    let i = changeset.elements.length;
    while (i--) {
      const elem = changeset.elements[i];
      if (isNullish(elem)) { continue; }
      const changes = {};
      changes[attrName] = changeset.oldValues[i];
      if (changes[attrName] !== elem.getAttribute(attrName)) {
        batchCmd.addSubCommand(new ChangeElementCommand(elem, changes, attrName));
      }
    }
    this.undoableChangeStack[p] = null;
    return batchCmd;
  }
}