1// Copyright 2014 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 */ 17function 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 */ 54CommandQueue.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 */ 66CommandQueue.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 */ 76CommandQueue.prototype.isBusy = function() { return this.busy_ }; 77 78/** 79 * Set the queue state to busy. Lock the UI. 80 * @private 81 */ 82CommandQueue.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 */ 98CommandQueue.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 */ 119CommandQueue.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 */ 132CommandQueue.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 */ 159CommandQueue.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 */ 173CommandQueue.prototype.canUndo = function() { 174 return this.undo_.length != 0; 175}; 176 177/** 178 * Undo the most recent command. 179 */ 180CommandQueue.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 */ 233CommandQueue.prototype.canRedo = function() { 234 return this.redo_.length != 0; 235}; 236 237/** 238 * Repeat the command that was recently un-done. 239 */ 240CommandQueue.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 */ 251CommandQueue.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 */ 270function Command(name) { 271 this.name_ = name; 272} 273 274/** 275 * @return {string} String representation of the command. 276 */ 277Command.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 */ 293Command.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 */ 304Command.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 */ 319Command.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 */ 334Command.Rotate = function(rotate90) { 335 Command.call(this, 'rotate(' + rotate90 * 90 + 'deg)'); 336 this.rotate90_ = rotate90; 337}; 338 339Command.Rotate.prototype = { __proto__: Command.prototype }; 340 341/** @override */ 342Command.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 */ 359Command.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 rectangle in image coordinates. 368 * @constructor 369 * @extends {Command} 370 */ 371Command.Crop = function(imageRect) { 372 Command.call(this, 'crop' + imageRect.toString()); 373 this.imageRect_ = imageRect; 374}; 375 376Command.Crop.prototype = { __proto__: Command.prototype }; 377 378/** @override */ 379Command.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 */ 392Command.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 */ 406Command.Filter = function(name, filter, message) { 407 Command.call(this, name); 408 this.filter_ = filter; 409 this.message_ = message; 410}; 411 412Command.Filter.prototype = { __proto__: Command.prototype }; 413 414/** @override */ 415Command.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