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}}