Most desktop applications enable the user to undo and redo their changes. Users expect this functionality from a desktop application, so they will expect it from an AIR application. I've seen quite a few people on the internet ask about how to do this.
Every time the document is changed, we save the previous state on a list. To undo, we take the data at the current position in the list, and use it to restore the document. We also need to save the document state before we restore it. This enables us to redo.
We maintain a list (an Array) in which we store the past states of the document.
First, let's define a class for the elements of this list in which we will save this state.
package { // HistoryItem.as by Daniel Freeman http://www.e2easy.co.cc
public class HistoryItem {
public var action:String;
public var actionParameters:Array;
public var redoAction:String;
public var redoParameters:Array;
public var repeat:Boolean=false;
public function HistoryItem(action:String,actionParameters:Array) {
this.action=action;
this.actionParameters=actionParameters;
}
}
}
This is just a collection of variables.
action is the type of action that caused the document to change. Did we type text?, Did we delete something? Did we insert a picture? etc.
actionParameters specifies all the information we need to restore the document to that state.
redoAction is the action that is required to recover the previous state before "Undo".
redoParameters specifies all the information we need to redo.
(Object Oriented purists will tell you that you shouldn't make the properties of a class directly accessible (public), but let's pretend we don't know that.)
Now let's define our History class. This class will maintain a list of HistoryItem elements (historystack).
index is the current position in this list. It slides up and down the list as we undo and redo.
We call the record() method every time we make a change to our document. The undo() method returns a document state that enables us to revert to that state.
When we undo, we must also save the document state that enables us to redo(). We call the redoaction() method to save this state.
package { // History.as by Daniel Freeman http://www.e2easy.co.cc
import flash.display.Stage;
public class History {
// We truncate the historystack to this length.
private const loglength:int=50;
// The list of document states.
private var historystack:Array=new Array();
// and the current position in the list.
private var index:int=-1;
// We must call redoAction() when we Undo.
// But we must only call it once.
// undomode just ensures that we play by the rules.
private var undomode:Boolean=false;
public function History() {} // Nothing in the constructor.
public function get canundo():Boolean {
return index>=0;
}
public function get canredo():Boolean {
return index<historystack.length-1;
}
public function undo():HistoryItem {
if (canundo) {
undomode=true;
// Just return a document state and decrement the current position.
return historystack[index--];
} else trace("*Can't undo");
return null;
}
public function redo():HistoryItem {
if (canredo) {
if (undomode) {
trace('*Error in redo. '+historystack[index+1].action);
} else {
// Return a document state and increment the current position.
return historystack[++index];
}
} else trace("*Can't redo");
return null;
}
public function record(action:String,params:Array):void {
// Remove redo states.
historystack.splice(index+1,historystack.length-(index+1));
// Record the document state at the next available position in the list.
historystack.push(new HistoryItem(action,params));
// Truncate the historystack.
if (historystack.length>loglength) historystack.splice(0,1);
// Set the current position.
index=historystack.length-1;
}
public function redoaction(action:String,params:Array):void {
var item:HistoryItem;
// A bit of logic that ensures we play by the rules.
if (undomode) {
item=historystack[index+1];
if (item==null) {
trace('**Error in redoevents. Unexpected redo.');
} else {
// Record the document state that enables us to redo().
item.redoAction=action;
item.redoParameters=params;
}
undomode=false;
} else trace('**Error in redoevents. Multiple redo.');
}
}
}
Now let's write a simple application that makes use of our History Class. Not a very useful application, but it demonstrates how to use the class.
The application places a row of shapes in the window. The user may drag these shapes around.
When the user clicks on a shape, we save the previous position and depth of that shape before we move it. This enables us to undo that move.
We listen for keyboard strokes. 'Z' is undo. 'Y' is redo. If the user clicks either 'Z' or 'Y', we restore the document state to the value that is returned from the History.undo() or History.redo() method. If the user has clicked 'Z' (undo), we make sure to save the document state that will enable the user to redo.
package { // MoveShapes.as by Daniel Freeman http://www.e2easy.co.cc
import flash.display.Sprite;
import flash.events.MouseEvent;
import flash.events.KeyboardEvent;
public class MoveShapes extends Sprite {
// Note that you don't need Flex to turn this pure ActionScript 3 example into an AIR application.
// You can use free command line tools.
// amxmlc (compiler) and adt (which converts a .swf file into a .air file).
private static const MOVESHAPE:String='MoveShapes.MOVESHAPE';
private const colours:Array=[0xccccff,0xccffcc,0xffcccc,0xffffcc];
private const gap:int=128;
private const margin:int=64;
private const ycoord:int=128;
private var history:History=new History();
public function MoveShapes(screen:Sprite=null) {
if (screen!=null) screen.addChild(this);
// Put some coloured shapes on the screen.
for (var i:int=0;i<colours.length;i++) {
new MyShape(this,i*gap+margin,ycoord,colours[i],i+3);
}
// Set up listeners.
addEventListener(MouseEvent.MOUSE_DOWN,mouseDownEvent);
stage.addEventListener(KeyboardEvent.KEY_DOWN,keypressed);
}
private function mouseDownEvent(ev:MouseEvent):void {
var shape:MyShape=ev.target as MyShape;
// This is the important bit. The document is about to change.
// Make sure you save everything that you need to restore it.
// The position of the shape, before you move it. And its depth, before you bring it to the front.
history.record(MOVESHAPE,[shape,shape.x,shape.y,getChildIndex(shape)]);
// Change the depth of the shape. Bring it to the front.
setChildIndex(shape,numChildren-1);
// Start dragging the shape.
shape.startDrag();
// Set up a listener to detect mouseup.
stage.addEventListener(MouseEvent.MOUSE_UP,mouseUpEvent);
}
private function mouseUpEvent(ev:MouseEvent):void {
// Stop dragging.
stopDrag();
stage.removeEventListener(MouseEvent.MOUSE_UP,mouseUpEvent);
}
private function keypressed(ev:KeyboardEvent):void {
var ch:String=String.fromCharCode(ev.charCode).toLowerCase();
// Press z=undo. press y=redo.
switch (ch) {
case 'z':undo();break;
case 'y':redo();
}
}
private function undo():void {
var obj:HistoryItem=history.undo();
// Notice we are passing a true canundo flag here.
if (obj!=null) doaction(obj.action,obj.actionParameters,true);
}
private function redo():void {
var obj:HistoryItem=history.redo();
// And notice we are passing false here.
if (obj!=null) doaction(obj.redoAction,obj.redoParameters,false);
}
private function doaction(action:String,parameters:Array,canundo:Boolean):void {
// Depending on the action, we call a specific method that restores the document state for that action.
// We pass the canundo flag to this method.
switch (action) {
case MOVESHAPE:restore(parameters,canundo);break;
// There is only one type of action in this simple example.
// But a real application would probably have several possible actions.
// Several cases.
}
}
private function restore(parameters:Array,canundo:Boolean):void {
var what:MyShape=parameters[0] as MyShape; // Which shape was affected?
// When we do an Undo, we must save a document state enabling us to Redo.
// Just like before, we save the position of the shape and its depth.
if (canundo) history.redoaction(MOVESHAPE,[what,what.x,what.y,getChildIndex(what)]);
// Restore position.
what.x=parameters[1] as Number;
what.y=parameters[2] as Number;
// Restore depth.
setChildIndex(what,parameters[3] as int);
}
}
}
This example only deals with one kind of action. Moving a shape. A sophisticated AIR application such as e2vector has many kinds of action. Creating shapes, removing shapes, text operations, changing colours, etc. Hence the doaction() method would contain a case for each action, calling many methods similar to restore().
There's one class left to complete this program. Just a simple class that draws a coloured polygon.
package { // MyShape.as by Daniel Freeman http://www.e2easy.co.cc
import flash.display.Sprite;
public class MyShape extends Sprite {
private const size:int=50; // The shape size.
public function MyShape(screen:Sprite,xx:int,yy:int,colour:uint,sides:int) {
const theta:Number=Math.PI*2/sides;
screen.addChild(this); // Put the shape on the displaylist
x=xx;y=yy; // Place it in the right position
graphics.beginFill(colour);
// Knock out some bits to make the colour darker.
graphics.lineStyle(1,colour & 0x7f7f7f);
graphics.moveTo(0,-size);
for (var i:int=1;i<=sides;i++) {
// We equally space points around a circle and join them up with straight lines.
// Viola! A polygon.
graphics.lineTo(size*Math.sin(i*theta),-size*Math.cos(i*theta));
}
// I want a hand cursor when the mouse is over this shape.
buttonMode=useHandCursor=true;
}
}
}
When you run this demonstration, press 'Z' to undo, and 'Y' to redo. Of course in a real application, you would put undo and redo options in the edit menu, then listen and respond to menu events.
That's all there is to it. To apply this technique to your own application, you just need to add your own cases to the doaction() method, and your own variations on restore().
+