Skip to content

Instantly share code, notes, and snippets.

@ricobl
Created January 12, 2013 02:58
Show Gist options
  • Save ricobl/4515810 to your computer and use it in GitHub Desktop.
Save ricobl/4515810 to your computer and use it in GitHub Desktop.
Workarounds for fixing Aloha Editor Undo Plugin
define([
'aloha',
'jquery',
'aloha/plugin',
'ui/ui',
'ui/button',
'aloha/console',
'undo/vendor/undo',
'undo/vendor/diff_match_patch_uncompressed'
],
function(Aloha, jQuery, Plugin, Ui, Button, Console) {
var dmp = new diff_match_patch;
function reversePatch(patch) {
var reversed = dmp.patch_deepCopy(patch);
for (var i = 0; i < reversed.length; i++) {
for (var j = 0; j < reversed[i].diffs.length; j++) {
reversed[i].diffs[j][0] = -(reversed[i].diffs[j][0]);
}
}
return reversed;
}
function enableBlocks() {
if (!Aloha.settings.plugins.block.defaults) {
Aloha.settings.plugins.block.defaults = {};
}
var editable = plugin.getRootEditable(Aloha.getActiveEditable());
if ( editable !== null ) {
var $editor = jQuery(editable.obj);
jQuery.each( Aloha.settings.plugins.block.defaults, function(selector, instanceDefaults) {
$editor.find(selector).alohaBlock(instanceDefaults);
});
}
}
var EditCommand = Undo.Command.extend({
constructor: function(editable, patch) {
this.editable = plugin.getRootEditable(editable);
this.patch = patch;
},
execute: function() {
//command object is created after execution.
},
undo: function() {
this.phase(reversePatch(this.patch));
},
redo: function() {
this.phase(this.patch);
},
phase: function(patch) {
var contents = this.editable.getContents(),
applied = dmp.patch_apply(patch, contents),
newValue = applied[0],
didNotApply = applied[1];
if (didNotApply.length) {
//error
}
this.reset(newValue);
},
reset: function(val) {
var reactivate = null;
if (Aloha.getActiveEditable() === this.editable) {
Aloha.deactivateEditable();
reactivate = this.editable;
}
this.editable.obj.html(val);
if (null !== reactivate) {
reactivate.activate();
}
this.editable.oldContents = val;
}
});
/**
* register the plugin with unique name
*/
var plugin = Plugin.create('undo', {
/**
* Initialize the plugin and set initialize flag on true
*/
init: function () {
plugin.stack = new Undo.Stack();
plugin.stack.changed = function() {
plugin.updateButtons();
};
plugin.createButtons();
// @todo use aloha hotkeys here
jQuery(document).keydown(function(event) {
if (!event.metaKey || event.keyCode != 90) {
return;
}
event.preventDefault();
//Before doing an undo, bring the smartContentChange
//event up to date.
plugin.takeSnapshot();
if (event.shiftKey) {
plugin.redo();
} else {
plugin.undo();
}
});
Aloha.bind('aloha-smart-content-changed', function(jevent, aevent) {
plugin.takeSnapshot(aevent.editable);
});
Aloha.bind('aloha-editable-activated', function(event, rangeObject) {
plugin.updateButtons();
});
},
takeSnapshot: function(editable){
if (editable == null) {
editable = Aloha.getActiveEditable();
}
editable = plugin.getRootEditable(editable);
var newValue = editable.getContents(),
oldValue = editable.oldContents || editable.getSnapshotContent(),
patch = dmp.patch_make(oldValue, newValue);
// getContents triggers content-handlers and the makeClean method of
// plugins. The table plugin calls mahaloBlock on table wrappers.
Aloha.jQuery('.aloha-table-wrapper').alohaBlock();
// only push an EditCommand if something actually changed.
if (0 !== patch.length) {
plugin.stack.execute( new EditCommand( editable, patch ) );
editable.oldContents = newValue;
}
},
getRootEditable: function (editable) {
if (!editable || !editable.obj) {
Console.warn('undo', 'editable.obj not found.');
return Aloha.editables[0];
}
// Get top-most aloha-editable
var root = editable.obj.parents('.aloha-editable').last();
if (!root.length) {
if (editable.obj.parents('body').length) {
return editable;
}
else {
// Sometimes the editable is dettached from DOM (deleted blocks?)
Console.warn('undo', 'Dettached editable, falling back to editable[0]');
return Aloha.editables[0];
}
}
var rootEditable;
$.each(Aloha.editables, function(){
if (this.obj[0] === root[0]) {
rootEditable = this;
}
});
return rootEditable;
},
undo: function(){
if (plugin.stack.canUndo()){
plugin.stack.undo();
enableBlocks();
}
},
redo: function(){
if (plugin.stack.canRedo()){
plugin.stack.redo();
enableBlocks();
}
},
updateButtons: function () {
plugin.undoButton.element.button('option', 'disabled', !plugin.stack.canUndo());
plugin.redoButton.element.button('option', 'disabled', !plugin.stack.canRedo());
},
createButtons: function () {
plugin.undoButton = Ui.adopt('undo', Button, {
tooltip: 'Undo',
icon: 'aloha-button-undo',
scope: 'Aloha.continuoustext',
click: function() {
plugin.undo();
}
});
plugin.redoButton = Ui.adopt('redo', Button, {
tooltip: 'Redo',
icon: 'aloha-button-redo',
scope: 'Aloha.continuoustext',
click: function() {
plugin.redo();
}
});
plugin.updateButtons();
},
/**
* toString method
* @return string
*/
toString: function () {
return 'undo';
}
});
return plugin;
});
$(function(){
var $ = Aloha.jQuery;
// Disable animations on Aloha jQuery and external jQuery
$.fx.off = true;
jQuery.fx.off = true;
module("Undo", {
setup: function(){
this.$editor = $('.aloha-editable');
this.$editor.html('');
this.editable = Aloha.editables[0];
this.actionNumber = 1;
this.plugin = Aloha.require('undo/undo-plugin');
},
assertHasBlock: function(shouldHave){
var blockmanager = Aloha.require('block/blockmanager');
var markupBlock = this.$editor.find('.custom-block');
var block = blockmanager.getBlock(markupBlock);
var blockFound = Boolean(block);
equal(blockFound, shouldHave);
},
removeBlock: function(){
var blockmanager = Aloha.require('block/blockmanager');
var markupBlock = this.$editor.find('.custom-block');
var block = blockmanager.getBlock(markupBlock);
block.destroy();
},
activateEditable: function() {
this.editable.activate();
this.$editor.focus();
GENTICS.Utils.Dom.selectDomNode(this.$editor.find(':last').parent().get(0));
Aloha.Selection.updateSelection();
},
createChange: function(){
var edicao1 = $("<p>Action "+this.actionNumber+"<br></p>");
this.actionNumber++;
this.$editor.append(edicao1);
this.editable.smartContentChange({type : 'blur'});
},
insertBlock: function(){
// We used our own plugin for testing.
// The requirements would be to insert some markup and install
// block behaviour.
// A selector should be mapped to any registered block to allow
// undo / redo to be tested.
// Using: Aloha.settings.plugins.block.defaults[selector] = {props};
},
createChangeWithBlock: function(){
this.activateEditable();
this.insertBlock();
},
simulateCtrlZ: function(){
var event = jQuery.Event("keydown");
// ctrl + z
event.metaKey = true;
event.keyCode = 90;
return event;
},
triggerUndo: function(){
this.activateEditable();
var Kevent = this.simulateCtrlZ();
this.$editor.trigger(Kevent);
},
triggerRedo: function(){
var Kevent = this.simulateCtrlZ();
Kevent.shiftKey = true;
this.$editor.trigger(Kevent);
}
});
test("Should undo one action", function(){
this.createChange();
var expected = this.$editor.html();
this.createChange();
this.triggerUndo();
var actual = this.$editor.html();
equal(actual, expected, "Previous state should be restored.");
});
test("Should undo two actions", function(){
var expected = this.$editor.html();
this.createChange();
this.createChange();
this.triggerUndo();
this.triggerUndo();
var actual = this.$editor.html();
equal(actual, expected, "Previous state should be restored.");
});
test("Should redo after undo", function(){
this.createChange();
this.createChange();
var expected = this.$editor.html();
this.triggerUndo();
this.triggerRedo();
var actual = this.$editor.html();
equal(actual, expected, "Previous state should be restored.");
});
test("Should redo two actions after two undos", function(){
this.createChange();
this.createChange();
var expected = this.$editor.html();
this.triggerUndo();
this.triggerUndo();
this.triggerRedo();
this.triggerRedo();
var actual = this.$editor.html();
equal(actual, expected, "Previous state should be restored.");
});
test("Redo should restore blocks", function(){
this.createChange();
this.createChangeWithBlock();
this.assertHasBlock(true);
this.triggerUndo();
this.assertHasBlock(false);
this.triggerRedo();
this.assertHasBlock(true);
});
test("Undo should restore blocks", function(){
this.createChange();
this.createChangeWithBlock();
this.assertHasBlock(true);
this.removeBlock();
this.assertHasBlock(false);
this.triggerUndo();
this.assertHasBlock(true);
});
module('getRootEditable', {
setup: function(){
this.$editor = $('.aloha-editable');
this.$editor.html('<div class="custom-block"></div>');
this.plugin = Aloha.require('undo/undo-plugin');
},
getFakeEditable: function(obj){
return {
obj: obj
};
}
});
test("Should return the same editable when it's the root", function(){
var editableOriginal = this.getFakeEditable($('.aloha-editable'));
var editable = this.plugin.getRootEditable(editableOriginal);
equal(editable, editableOriginal);
});
test("Should return the root editable when current is nested", function(){
var editableBloco = this.getFakeEditable($('.custom-block'));
var fakeEditable = this.getFakeEditable($('.aloha-editable'));
var editable = this.plugin.getRootEditable(editableBloco);
equal(editable, Aloha.editables[0]);
});
test("Should return the first editable when none is passed", function(){
var editableBloco = this.getFakeEditable($('.custom-block'));
var fakeEditable = this.getFakeEditable($('.aloha-editable'));
var editable = this.plugin.getRootEditable();
equal(editable, Aloha.editables[0]);
});
test("Should return the first editable when current has no DOM object", function(){
var editableBloco = this.getFakeEditable($('.custom-block'));
var fakeEditable = this.getFakeEditable($('.aloha-editable'));
var editable = this.plugin.getRootEditable({});
equal(editable, Aloha.editables[0]);
});
test("Should return the first editable when the element is dettached", function(){
var $markupBlock = $('.custom-block').detach();
var dettachedEditable = this.getFakeEditable($markupBlock);
var fakeEditable = this.getFakeEditable($('.aloha-editable'));
var editable = this.plugin.getRootEditable(dettachedEditable);
equal(editable, Aloha.editables[0]);
});
});

Workarounds for fixing Aloha Editor Undo Plugin

Here is a gist of a modified undo-plugin with a series of workarounds to make it work.

I'm not submitting this as a pull-request because it's a dirty workaround, there should be a little more thought to make Aloha cooperate better with the undo-plugin.

getSnapshotContent

The method aevent.getSnapshotContent is not as up-to-date as it should, as a result the diff_match_patch plugin always finds differences and generates changes after an undo or redo.

The undo operation keeps toggling the last edit.

Our solution was to ignore this method and assign an oldContent property to the current editable.

Delayed snapshots

Sometimes snapshots are taken just before the undo or redo operations, making it difficult to create proper undo / redo buttons that are aware of the current stack position.

It should be possible to catch changes more accurately.

Our solution was to extract the snapshot logic from the smart-content-change listener to an exposed method (takeStapshot) of the undo plugin and use it whenever possible, mostly after DOM insertions from our plugins.

Content API

It would be nice to have an API to manipulate content in an editable. The current GENTICS.Utils.Dom is completely decoupled from the editor, as it should, but there could be a simpler layer between Aloha and these helpers.

This API could transparently trigger events to allow the undo plugin to take snapshots.

Also, the smart-content-change event could rely less on timeouts and delimiters and more on this API.

Nested editables

The undo / redo commands are associated with the active editable. This is a problem when there are nested editables like blocks with editable regions.

The following workflow may explain better the issue:

  • insert a nested editable
  • type some text in the new editable
  • undoing the text works
  • undoing the nested editable insert works
  • redoing the insert works
  • redoing the text doesn't work because the stack command is associated with an editable whose DOM obj is dettached from the document, the action has no effect

Our solution was to always find a "root editable", which is the current or outer-most editable wrapping other editables. As long as it is attached to the document, it can hold other editables modifications.

Table plugin

The getContents method of an editor uses the makeClean methods of plugins for custom sanitization of content.

The table plugin makeClean method operates on the actual editable element instead of sanitizing the cloned content. This forces editing controls and the block behaviour to be removed, using table.deactivate and mahaloBlock.

To avoid this, it relies on the editor activation / deactivation after undo or redo operations to re-apply the editing controls on the original editable, but the block behaviour is lost.

This re-activation seems to be there just to cover the table plugin editing behaviour.

Our solution was to listen to table-activated events and installing the appropriate block behaviour.

Blocks

Block behaviour are not automatically restored after an undo or redo.

The better solution we found was to read settings and reinstall blocks. There could be an exposed method for this as it is used on editable creation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment