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