La funcionalidad deshacer y repetir en JavaScript para Windows 8


Undo button
Los participantes de un hackathon tienen que poner todo su empeño y mucho más para conseguir en pocos días crear una aplicación brillante; eso les obliga a buscar soluciones para problemas que no se habían planteado nunca antes. Durante los hackathones, megathones y demás formaciones que he ido dando sobre Windows 8 suelen surgir dudas más profundas que en un curso estándar sobre algún lenguaje o tecnología concretos.

Una de las cuestiones más recurrentes suele ser la funcionalidad deshacer en una aplicación, ya sea una calculadora o un programa de dibujo. En este artículo voy a intentar solucionaros esa duda con un ejemplo de código.

Descarga el código fuente de esta aplicación desde codeplex

Patrones de diseño

Como no me gusta reinventar la rueda vamos a hacer uso de uno de los dos patrones de diseño del GoF que se suelen utilizar en estos casos, a saber:

  • Memento: nos permite almacenar el estado de un objeto, respetando la encapsulación del mismo, para poder recuperarlo después.
  • Command: encapsula una petición como un objeto, con el método y los parámetros que va a utilizar sobre el receptor de la petición. Esto nos permite crear una cola de solicitudes y permitir el retroceso de operaciones (siempre que la operación lo permita).

Dependiendo del caso utilizaremos un método u otro. Por ejemplo, en el caso de un programa de dibujo sobre lienzo el Memento nos puede resultar más fácil de implementar, pues cada vez que se dibuje un elemento podemos guardar una foto del mismo, o una porción para optimizar memoria. En el caso de un procesador de textos el patrón Command seguramente será mucho más óptimo, pero cada comando tendrá que implementar su propia función deshacer.

Para hacer un ejercicio sencillo vamos a basarnos en el patrón memento para desarrollar un ejemplo.

El patrón Memento en JavaScript

En el patrón Memento utilizamos tres objetos:

  • Memento: es el almacén del estado interno del objeto, guarda la información necesaria para recuperar el estado del objeto a un momento concreto
  • Originator: crea el Memento que contiene la instantánea de su estado interno y sabe restaurar ese estado usando el Memento
  • Caretaker: gestiona la lista de Mementos, pero no utiliza nunca la información del Memento directamente.

Para demostrar el uso del patrón utilizaremos una aplicación de pintado en un canvas ya utilizada en este blog para tratar los eventos táctiles.

Empezaremos por definir un espacio de nombres para evitarnos problemas en JavaScript y definiremos el objeto básico Memento:

(function mementoDefinition(global) {
    global.CanvasState = global.CanvasState || {};

    ///Memento
    CanvasState.memento = function (state) {
        this._state = state;
    };
    Object.defineProperty(CanvasState.memento.prototype, "state", {
        get: function () { return this._state; }
    });
    ///...

Podréis observar que es extremadamente sencillo: un constructor con el estado y una propiedad para leer el estado. No es modificable ni tiene ninguna funcionalidad, sólo almacena información.

El siguiente paso es crear el objeto Caretaker, el que se encarga de almacenar la lista de objetos Memento; necesitaremos un Array para almacenar los estados de deshacer y otro para los estados de rehacer.

En esta implementación ponemos un parámetro de límite, para evitar llenar toda la memoria con las operaciones de undo. Para gestionar los estados tenemos las funciones addMemento, que nos permite ir añadiendo estados a la lista y las funciones getUndoMemento y getRedoMemento para recuperar los estados, dependiendo de si queremos deshacer una operación o volver a hacerla desde el historial.

///Caretaker constructor
CanvasState.caretaker = function (maxLevels) {
    this._undoStates = [];
    this._redoStates = [];
    this._maxLevels = maxLevels;
};

CanvasState.caretaker.prototype.addMemento = function (memento) {
    this._undoStates.push(memento);
    this._redoStates = [];
    if (this._undoStates.length > this._maxLevels) {
        this._undoStates.splice(0, 1);
    }
};

CanvasState.caretaker.prototype._canUndo = function () {
    return this._undoStates.length > 1;
}
CanvasState.caretaker.prototype._canRedo = function () {
    return this._redoStates.length > 0;
}

CanvasState.caretaker.prototype.getUndoMemento = function () {
    if (this._canUndo()) {
        var state = this._undoStates.pop();
        this._redoStates.push(state);
        return this._undoStates[this._undoStates.length - 1];
    }
    else
        throw "Undo not allowed, states array empty";
};
CanvasState.caretaker.prototype.getRedoMemento = function () {
    if (this._canRedo()) {
        var state = this._redoStates.pop();
        this._undoStates.push(state);
        return state;
    }
    else
        throw "Redo not allowed, states array empty";
}

Object.defineProperty(CanvasState.caretaker.prototype, "canUndo", {
    get: function () { return this._canUndo(); }
});
Object.defineProperty(CanvasState.caretaker.prototype, "canRedo", {
    get: function () { return this._canRedo(); }
});

Por ahora, con estos dos objetos no hemos realizado ninguna operación con el objeto canvas, sólo tenemos el objeto que almacena el estado y el que se encarga de mantenerlo en memoria.

El tercer y último objeto es el Originator, donde ocurren todas las operaciones. Este objeto sabe cómo crear un Memento y cómo recuperar el estado a partir del mismo. En el ejemplo vamos a utilizar el objeto canvas para recuperar una imagen y la guardaremos dentro de un Memento que podremos utilizar más adelante para restaurar el estado.

    ///Originator
    CanvasState.originator = function (canvas) {
        this._canvas = canvas;
        this._ctx = canvas.getContext("2d");
    };
    CanvasState.originator.prototype.saveToMemento = function (x, y, w, h) {
        if (x === undefined)
            x = 0;
        if (y === undefined)
            y = 0;
        if (w === undefined)
            w = this._canvas.width;
        if (h === undefined)
            h = this._canvas.height;

        var img = this._ctx.getImageData(x, y, w, h);
        return new CanvasState.memento({ image: img, x: x, y: y, w: w, h: h });
    };
    CanvasState.originator.prototype.restoreFromMemento = function (memento) {
        this._ctx.putImageData(memento.state.image, memento.state.x, memento.state.y);
    };

})(this);

Y así cerramos la función auto-ejecutable que define todos nuestros objetos. Todo el código anterior lo podemos meter en un archivo llamado memento.js y así lo podremos reutilizar fácilmente.

Como podréis observar, en todo el código el único objeto que sabe algo del canvas es el objeto originator.

Aplicación del patrón a un caso real

Ahora que tenemos todos los objetos preparados, vamos a utilizarlos en la aplicación que hicimos en un post anterior sobre los eventos táctiles. Para demostrar los eventos pintábamos sobre un canvas y ahora vamos a ir guardando el estado para poder recuperarlo más tarde.

Para empezar, en default.js añadimos unas variables que vamos a utilizar para la nueva funcionalidad, justo debajo de las declaraciones del canvas y el ctx:

var originator, caretaker;

Seguimos en default.js, tras los eventos táctiles dentro de app.onactivated, crearemos los objetos originator y un caretaker de 20 niveles de deshacer:

// creamos el originator sobre nuestro canvas
// y un caretaker de 20 niveles
originator = new CanvasState.originator(canvas);
caretaker = new CanvasState.caretaker(20);

//lo primero que haremos será añadir el canvas vacío como memento.
caretaker.addMemento(originator.saveToMemento());

La función startDrawing quedará exactamente igual, donde vamos a guardar el estado es al levantar el dedo de la pantalla, en la función stopDrawing, que quedará así:

function stopDrawing(e) {
    doDraw = false;
    caretaker.addMemento(originator.saveToMemento(0, 0, canvas.width, canvas.height));
}

Ahora guardamos los cambios cada vez que levantamos el dedo de la pantalla. Nos falta poder recuperarlos. Para ello vamos a añadir una barra de botones en la página default.html justo debajo del canvas:

<div id="appBar" data-win-control="WinJS.UI.AppBar" data-win-options="{sticky:true}">
    <button data-win-control="WinJS.UI.AppBarCommand" 
        data-win-options="{id:'cmdUndo',label:'Undo',icon:'undo',section:'global'}">
    </button>
    <button data-win-control="WinJS.UI.AppBarCommand" 
        data-win-options="{id:'cmdRedo',label:'Redo',icon:'redo',section:'global'}">
    </button>
</div>

La marcamos como sticky para que no desaparezca mientras pintamos y nos resulte más cómoda de utilizar. Añadimos dos manejadores de evento en default.js, después de las líneas que crean el originator y el caretaker:

document.getElementById('cmdUndo').winControl.addEventListener("click", function () {
   if (caretaker.canUndo)
      originator.restoreFromMemento(caretaker.getUndoMemento());
});

document.getElementById('cmdRedo').winControl.addEventListener("click", function () {
   if (caretaker.canRedo)
      originator.restoreFromMemento(caretaker.getRedoMemento());
});

Vuestro turno

Está claro que a este código le queda mucho margen de mejora, así que hoy os voy a poner deberes:

  • Intentad mejorar el dibujado cuando hay múltiples puntos táctiles simultáneos
  • Para mejorar el rendimiento y la gestión de memoria de la app, deberíamos almacenar sólo la zona que se ha modificado del canvas en lugar de guardarlo todo. ¿Os atrevéis?

Descarga el código fuente de esta aplicación desde codeplex

Anuncios

Un Comentario

  1. Pingback: Optimizar el Canvas de Apps Windows en HTML5/JavaScript | Mouseless Me

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s