Home | History | Annotate | Download | only in image_editor
      1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 'use strict';
      6 
      7 /**
      8  * Command queue is the only way to modify images.
      9  * Supports undo/redo.
     10  * Command execution is asynchronous (callback-based).
     11  *
     12  * @param {Document} document Document to create canvases in.
     13  * @param {HTMLCanvasElement} canvas The canvas with the original image.
     14  * @param {function(callback)} saveFunction Function to save the image.
     15  * @constructor
     16  */
     17 function CommandQueue(document, canvas, saveFunction) {
     18   this.document_ = document;
     19   this.undo_ = [];
     20   this.redo_ = [];
     21   this.subscribers_ = [];
     22   this.currentImage_ = canvas;
     23 
     24   // Current image may be null or not-null but with width = height = 0.
     25   // Copying an image with zero dimensions causes js errors.
     26   if (this.currentImage_) {
     27     this.baselineImage_ = document.createElement('canvas');
     28     this.baselineImage_.width = this.currentImage_.width;
     29     this.baselineImage_.height = this.currentImage_.height;
     30     if (this.currentImage_.width > 0 && this.currentImage_.height > 0) {
     31       var context = this.baselineImage_.getContext('2d');
     32       context.drawImage(this.currentImage_, 0, 0);
     33     }
     34   } else {
     35     this.baselineImage_ = null;
     36   }
     37 
     38   this.previousImage_ = document.createElement('canvas');
     39   this.previousImageAvailable_ = false;
     40 
     41   this.saveFunction_ = saveFunction;
     42   this.busy_ = false;
     43   this.UIContext_ = {};
     44 }
     45 
     46 /**
     47  * Attach the UI elements to the command queue.
     48  * Once the UI is attached the results of image manipulations are displayed.
     49  *
     50  * @param {ImageView} imageView The ImageView object to display the results.
     51  * @param {ImageEditor.Prompt} prompt Prompt to use with this CommandQueue.
     52  * @param {function(boolean)} lock Function to enable/disable buttons etc.
     53  */
     54 CommandQueue.prototype.attachUI = function(imageView, prompt, lock) {
     55   this.UIContext_ = {
     56     imageView: imageView,
     57     prompt: prompt,
     58     lock: lock
     59   };
     60 };
     61 
     62 /**
     63  * Execute the action when the queue is not busy.
     64  * @param {function} callback Callback.
     65  */
     66 CommandQueue.prototype.executeWhenReady = function(callback) {
     67   if (this.isBusy())
     68     this.subscribers_.push(callback);
     69   else
     70     setTimeout(callback, 0);
     71 };
     72 
     73 /**
     74  * @return {boolean} True if the command queue is busy.
     75  */
     76 CommandQueue.prototype.isBusy = function() { return this.busy_ };
     77 
     78 /**
     79  * Set the queue state to busy. Lock the UI.
     80  * @private
     81  */
     82 CommandQueue.prototype.setBusy_ = function() {
     83   if (this.busy_)
     84     throw new Error('CommandQueue already busy');
     85 
     86   this.busy_ = true;
     87 
     88   if (this.UIContext_.lock)
     89     this.UIContext_.lock(true);
     90 
     91   ImageUtil.trace.resetTimer('command-busy');
     92 };
     93 
     94 /**
     95  * Set the queue state to not busy. Unlock the UI and execute pending actions.
     96  * @private
     97  */
     98 CommandQueue.prototype.clearBusy_ = function() {
     99   if (!this.busy_)
    100     throw new Error('Inconsistent CommandQueue already not busy');
    101 
    102   this.busy_ = false;
    103 
    104   // Execute the actions requested while the queue was busy.
    105   while (this.subscribers_.length)
    106     this.subscribers_.shift()();
    107 
    108   if (this.UIContext_.lock)
    109     this.UIContext_.lock(false);
    110 
    111   ImageUtil.trace.reportTimer('command-busy');
    112 };
    113 
    114 /**
    115  * Commit the image change: save and unlock the UI.
    116  * @param {number=} opt_delay Delay in ms (to avoid disrupting the animation).
    117  * @private
    118  */
    119 CommandQueue.prototype.commit_ = function(opt_delay) {
    120   setTimeout(this.saveFunction_.bind(null, this.clearBusy_.bind(this)),
    121       opt_delay || 0);
    122 };
    123 
    124 /**
    125  * Internal function to execute the command in a given context.
    126  *
    127  * @param {Command} command The command to execute.
    128  * @param {Object} uiContext The UI context.
    129  * @param {function} callback Completion callback.
    130  * @private
    131  */
    132 CommandQueue.prototype.doExecute_ = function(command, uiContext, callback) {
    133   if (!this.currentImage_)
    134     throw new Error('Cannot operate on null image');
    135 
    136   // Remember one previous image so that the first undo is as fast as possible.
    137   this.previousImage_.width = this.currentImage_.width;
    138   this.previousImage_.height = this.currentImage_.height;
    139   this.previousImageAvailable_ = true;
    140   var context = this.previousImage_.getContext('2d');
    141   context.drawImage(this.currentImage_, 0, 0);
    142 
    143   command.execute(
    144       this.document_,
    145       this.currentImage_,
    146       function(result, opt_delay) {
    147         this.currentImage_ = result;
    148         callback(opt_delay);
    149       }.bind(this),
    150       uiContext);
    151 };
    152 
    153 /**
    154  * Executes the command.
    155  *
    156  * @param {Command} command Command to execute.
    157  * @param {boolean=} opt_keep_redo True if redo stack should not be cleared.
    158  */
    159 CommandQueue.prototype.execute = function(command, opt_keep_redo) {
    160   this.setBusy_();
    161 
    162   if (!opt_keep_redo)
    163     this.redo_ = [];
    164 
    165   this.undo_.push(command);
    166 
    167   this.doExecute_(command, this.UIContext_, this.commit_.bind(this));
    168 };
    169 
    170 /**
    171  * @return {boolean} True if Undo is applicable.
    172  */
    173 CommandQueue.prototype.canUndo = function() {
    174   return this.undo_.length != 0;
    175 };
    176 
    177 /**
    178  * Undo the most recent command.
    179  */
    180 CommandQueue.prototype.undo = function() {
    181   if (!this.canUndo())
    182     throw new Error('Cannot undo');
    183 
    184   this.setBusy_();
    185 
    186   var command = this.undo_.pop();
    187   this.redo_.push(command);
    188 
    189   var self = this;
    190 
    191   function complete() {
    192     var delay = command.revertView(
    193         self.currentImage_, self.UIContext_.imageView);
    194     self.commit_(delay);
    195   }
    196 
    197   if (this.previousImageAvailable_) {
    198     // First undo after an execute call.
    199     this.currentImage_.width = this.previousImage_.width;
    200     this.currentImage_.height = this.previousImage_.height;
    201     var context = this.currentImage_.getContext('2d');
    202     context.drawImage(this.previousImage_, 0, 0);
    203 
    204     // Free memory.
    205     this.previousImage_.width = 0;
    206     this.previousImage_.height = 0;
    207     this.previousImageAvailable_ = false;
    208 
    209     complete();
    210     // TODO(kaznacheev) Consider recalculating previousImage_ right here
    211     // by replaying the commands in the background.
    212   } else {
    213     this.currentImage_.width = this.baselineImage_.width;
    214     this.currentImage_.height = this.baselineImage_.height;
    215     var context = this.currentImage_.getContext('2d');
    216     context.drawImage(this.baselineImage_, 0, 0);
    217 
    218     var replay = function(index) {
    219       if (index < self.undo_.length)
    220         self.doExecute_(self.undo_[index], {}, replay.bind(null, index + 1));
    221       else {
    222         complete();
    223       }
    224     };
    225 
    226     replay(0);
    227   }
    228 };
    229 
    230 /**
    231  * @return {boolean} True if Redo is applicable.
    232  */
    233 CommandQueue.prototype.canRedo = function() {
    234   return this.redo_.length != 0;
    235 };
    236 
    237 /**
    238  * Repeat the command that was recently un-done.
    239  */
    240 CommandQueue.prototype.redo = function() {
    241   if (!this.canRedo())
    242     throw new Error('Cannot redo');
    243 
    244   this.execute(this.redo_.pop(), true);
    245 };
    246 
    247 /**
    248  * Closes internal buffers. Call to ensure, that internal buffers are freed
    249  * as soon as possible.
    250  */
    251 CommandQueue.prototype.close = function() {
    252   // Free memory used by the undo buffer.
    253   this.previousImage_.width = 0;
    254   this.previousImage_.height = 0;
    255   this.previousImageAvailable_ = false;
    256 
    257   if (this.baselineImage_) {
    258     this.baselineImage_.width = 0;
    259     this.baselineImage_.height = 0;
    260   }
    261 };
    262 
    263 /**
    264  * Command object encapsulates an operation on an image and a way to visualize
    265  * its result.
    266  *
    267  * @param {string} name Command name.
    268  * @constructor
    269  */
    270 function Command(name) {
    271   this.name_ = name;
    272 }
    273 
    274 /**
    275  * @return {string} String representation of the command.
    276  */
    277 Command.prototype.toString = function() {
    278   return 'Command ' + this.name_;
    279 };
    280 
    281 /**
    282  * Execute the command and visualize its results.
    283  *
    284  * The two actions are combined into one method because sometimes it is nice
    285  * to be able to show partial results for slower operations.
    286  *
    287  * @param {Document} document Document on which to execute command.
    288  * @param {HTMLCanvasElement} srcCanvas Canvas to execute on.
    289  * @param {function(HTMLCanvasElement, number)} callback Callback to call on
    290  *   completion.
    291  * @param {Object} uiContext Context to work in.
    292  */
    293 Command.prototype.execute = function(document, srcCanvas, callback, uiContext) {
    294   console.error('Command.prototype.execute not implemented');
    295 };
    296 
    297 /**
    298  * Visualize reversion of the operation.
    299  *
    300  * @param {HTMLCanvasElement} canvas Image data to use.
    301  * @param {ImageView} imageView ImageView to revert.
    302  * @return {number} Animation duration in ms.
    303  */
    304 Command.prototype.revertView = function(canvas, imageView) {
    305   imageView.replace(canvas);
    306   return 0;
    307 };
    308 
    309 /**
    310  * Creates canvas to render on.
    311  *
    312  * @param {Document} document Document to create canvas in.
    313  * @param {HTMLCanvasElement} srcCanvas to copy optional dimensions from.
    314  * @param {number=} opt_width new canvas width.
    315  * @param {number=} opt_height new canvas height.
    316  * @return {HTMLCanvasElement} Newly created canvas.
    317  * @private
    318  */
    319 Command.prototype.createCanvas_ = function(
    320     document, srcCanvas, opt_width, opt_height) {
    321   var result = document.createElement('canvas');
    322   result.width = opt_width || srcCanvas.width;
    323   result.height = opt_height || srcCanvas.height;
    324   return result;
    325 };
    326 
    327 
    328 /**
    329  * Rotate command
    330  * @param {number} rotate90 Rotation angle in 90 degree increments (signed).
    331  * @constructor
    332  * @extends {Command}
    333  */
    334 Command.Rotate = function(rotate90) {
    335   Command.call(this, 'rotate(' + rotate90 * 90 + 'deg)');
    336   this.rotate90_ = rotate90;
    337 };
    338 
    339 Command.Rotate.prototype = { __proto__: Command.prototype };
    340 
    341 /** @override */
    342 Command.Rotate.prototype.execute = function(
    343     document, srcCanvas, callback, uiContext) {
    344   var result = this.createCanvas_(
    345       document,
    346       srcCanvas,
    347       (this.rotate90_ & 1) ? srcCanvas.height : srcCanvas.width,
    348       (this.rotate90_ & 1) ? srcCanvas.width : srcCanvas.height);
    349   ImageUtil.drawImageTransformed(
    350       result, srcCanvas, 1, 1, this.rotate90_ * Math.PI / 2);
    351   var delay;
    352   if (uiContext.imageView) {
    353     delay = uiContext.imageView.replaceAndAnimate(result, null, this.rotate90_);
    354   }
    355   setTimeout(callback, 0, result, delay);
    356 };
    357 
    358 /** @override */
    359 Command.Rotate.prototype.revertView = function(canvas, imageView) {
    360   return imageView.replaceAndAnimate(canvas, null, -this.rotate90_);
    361 };
    362 
    363 
    364 /**
    365  * Crop command.
    366  *
    367  * @param {Rect} imageRect Crop rectange in image coordinates.
    368  * @constructor
    369  * @extends {Command}
    370  */
    371 Command.Crop = function(imageRect) {
    372   Command.call(this, 'crop' + imageRect.toString());
    373   this.imageRect_ = imageRect;
    374 };
    375 
    376 Command.Crop.prototype = { __proto__: Command.prototype };
    377 
    378 /** @override */
    379 Command.Crop.prototype.execute = function(
    380     document, srcCanvas, callback, uiContext) {
    381   var result = this.createCanvas_(
    382       document, srcCanvas, this.imageRect_.width, this.imageRect_.height);
    383   Rect.drawImage(result.getContext('2d'), srcCanvas, null, this.imageRect_);
    384   var delay;
    385   if (uiContext.imageView) {
    386     delay = uiContext.imageView.replaceAndAnimate(result, this.imageRect_, 0);
    387   }
    388   setTimeout(callback, 0, result, delay);
    389 };
    390 
    391 /** @override */
    392 Command.Crop.prototype.revertView = function(canvas, imageView) {
    393   return imageView.animateAndReplace(canvas, this.imageRect_);
    394 };
    395 
    396 
    397 /**
    398  * Filter command.
    399  *
    400  * @param {string} name Command name.
    401  * @param {function(ImageData,ImageData,number,number)} filter Filter function.
    402  * @param {string} message Message to display when done.
    403  * @constructor
    404  * @extends {Command}
    405  */
    406 Command.Filter = function(name, filter, message) {
    407   Command.call(this, name);
    408   this.filter_ = filter;
    409   this.message_ = message;
    410 };
    411 
    412 Command.Filter.prototype = { __proto__: Command.prototype };
    413 
    414 /** @override */
    415 Command.Filter.prototype.execute = function(
    416     document, srcCanvas, callback, uiContext) {
    417   var result = this.createCanvas_(document, srcCanvas);
    418 
    419   var self = this;
    420 
    421   var previousRow = 0;
    422 
    423   function onProgressVisible(updatedRow, rowCount) {
    424     if (updatedRow == rowCount) {
    425       uiContext.imageView.replace(result);
    426       if (self.message_)
    427         uiContext.prompt.show(self.message_, 2000);
    428       callback(result);
    429     } else {
    430       var viewport = uiContext.imageView.viewport_;
    431 
    432       var imageStrip = new Rect(viewport.getImageBounds());
    433       imageStrip.top = previousRow;
    434       imageStrip.height = updatedRow - previousRow;
    435 
    436       var screenStrip = new Rect(viewport.getImageBoundsOnScreen());
    437       screenStrip.top = Math.round(viewport.imageToScreenY(previousRow));
    438       screenStrip.height =
    439           Math.round(viewport.imageToScreenY(updatedRow)) - screenStrip.top;
    440 
    441       uiContext.imageView.paintDeviceRect(
    442           viewport.screenToDeviceRect(screenStrip), result, imageStrip);
    443       previousRow = updatedRow;
    444     }
    445   }
    446 
    447   function onProgressInvisible(updatedRow, rowCount) {
    448     if (updatedRow == rowCount) {
    449       callback(result);
    450     }
    451   }
    452 
    453   filter.applyByStrips(result, srcCanvas, this.filter_,
    454       uiContext.imageView ? onProgressVisible : onProgressInvisible);
    455 };
    456