lamchau
4/2/2015 - 8:25 PM

ember.js: command pattern with browser history for undo/redo

ember.js: command pattern with browser history for undo/redo

/**
 * Command Dispatcher for managing {@code App.Command}.
 *
 * {@link http://en.wikipedia.org/wiki/Command_pattern|Command Pattern}
 */
App.CommandController = Ember.Controller.extend({
  commands: null,
  result: null,
  position: 0,
  maxRedo: 0,

  _init: function() {
    // we must reset complex types to avoid call by object references (i.e. call
    // by sharing) for each instance
    this.setProperties({
      commands: [],
      result: []
    });
  }.on("init"),

  execute: function(command) {
    Ember.assert("Argument must be an instance of App.Command",
      App.Command.detectInstance(command));

    this.commands[this.incrementProperty("position")] = command;
    this.setProperties({
      maxRedo: 0,
      result: command.execute(this.getResult()),
    });
  },

  getResult: function() {
    return this.get("result");
  },

  redo: function(count) {
    var command,
      position = this.get("position") + 1,
      maxRedo = this.get("maxRedo");

    count = Math.min(arguments.length ? Math.abs(count) : 1, maxRedo);
    for (var i = 0; i < count; i++) {
      command = this.commands[position + i];
      this.set("result", command.execute(this.getResult()));
      this.decrementProperty("maxRedo");
    }
    this.incrementProperty("position", count);
  },

  reset: function() {
    var position = this.get("position");

    this.setProperties({
      commands: [],
      maxRedo: 0,
      position: 0,
      result: [],
    });
    if (position > 0) {
      Ember.History.go(-position);
    }
  },

  undo: function(count) {
    var command,
      position = this.get("position");

    count = Math.min(arguments.length ? Math.abs(count) : 1, position);
    for (var i = 0; i < count; i++) {
      command = this.commands.get(position);
      this.set("result", command.undo(this.getResult()));
      this.incrementProperty("maxRedo");
      this.decrementProperty("position");
    }
  }
});
App.MyController = Ember.Controller.extend({
  breadcrumbs: [],
  
  actions: {
    navigate: function(index) {
      // navigation undo is current depth - clicked depth
      var breadcrumbs = this.get("breadcrumbs"),
        current = _.last(breadcrumbs),
        selected = breadcrumbs[index],
        count = current.depth - selected.depth;

      if (count > 0) {
        Ember.History.go(-count);
      }
    }
  },
  
  renderBreadcrumb: function(node, direction) {
    var nodes = [],
      parent = node;

    while (parent) {
      nodes.unshift(parent);
      parent = parent.parent;
    }

    if (direction === "up" && nodes) {
      nodes.pop();
    }
    this.set("breadcrumbs", nodes);
  }
});
App.NavigatorController = App.CommandController.extend({
  _popStateHandler: null,

  execute: function(command) {
    this._super(command);

    // events are only fired for moving back in history, so we need to use
    // 'position' as a unique id to determine direction
    var state = {
      direction: this.get("position")
    };
    Ember.History.pushState(state, null, window.location.href);
    this.setPreviousDirection(state.direction);
  },

  getPreviousDirection: function() {
    return this.get("previous");
  },

  onPopState: function(event) {
    var current = event.state ? event.state.direction : 0,
      previous = this.getPreviousDirection(),
      difference = current - previous,
      isRedo = difference > 0;

    // detect when we run out of custom history items
    if (!_.has(event.state, "direction")) {
      return;
    }

    difference = Math.abs(difference);
    // replaying one by one provides for reliable setting of browser state to
    // allow for navigation through the command history via browser buttons
    for (var i = 0; i < difference; i++) {
      if (isRedo) {
        this.redo();
      } else {
        this.undo();
      }
    }
    this.setPreviousDirection(current);
  },

  register: function() {
    var eventHandler = _.bind(this.onPopState, this);

    if (this.get("_popStateHandler")) {
      this.unregister();
    }
    this.set("_popStateHandler", eventHandler);
    window.addEventListener("popstate", eventHandler);
  },

  setPreviousDirection: function(direction) {
    this.set("previous", direction);
  },

  unregister: function() {
    var eventHandler = this.get("_popStateHandler");

    window.removeEventListener("popstate", eventHandler);
  }
});
(function(History) {
  if (!Ember.Object.detectInstance(History)) {
    throw new Error("Native `Ember.History` is already defined.");
  }

  if (Ember.Evented.detect(History)) {
    return;
  } else {
    History.reopen(Ember.Evented);
  }

  History.reopen({
    back: function() {
      window.history.go(-1);
    },

    forward: function() {
      window.history.go(1);
    },

    go: function(count) {
      // most likely an error, explicitly use reload when we mean it
      if (count === 0) {
        throw new Ember.Error("Use `window.location.reload()` instead");
      }
      Ember.assert("Count must be an integer", Math.round(count) === count);
      window.history.go(count);
      this.trigger("go", count);
    },

    pushState: function(state, title, url) {
      window.history.pushState(state, title, url);
      this.trigger("pushState", state, title, url);
    },

    replaceState: function(state, title, url) {
      window.history.replaceState(state, title, url);
      this.trigger("replaceState", state, title, url);
    }
  });
})(Ember.History || (Ember.History = Ember.Object.create()));
App.Command = Ember.Object.extend({
  execute: function() {
    Ember.assert("Method 'execute' not implemented");
  },
  undo: function() {
    // optional
  }
});
{{#if breadcrumbs}}
  <div>
    <ul>
      {{#each item in breadcrumbs~}}
        <li {{action "navigate" _view.contentIndex}}>{{item.name}}</li>
      {{/each}}
    </ul>
  </div>
{{/if}}