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 * ImageEditor is the top level object that holds together and connects
9 * everything needed for image editing.
10 *
11 * @param {Viewport} viewport The viewport.
12 * @param {ImageView} imageView The ImageView containing the images to edit.
13 * @param {ImageEditor.Prompt} prompt Prompt instance.
14 * @param {Object} DOMContainers Various DOM containers required for the editor.
15 * @param {Array.<ImageEditor.Mode>} modes Available editor modes.
16 * @param {function} displayStringFunction String formatting function.
17 * @param {function()} onToolsVisibilityChanged Callback to be called, when
18 *     some of the UI elements have been dimmed or revealed.
19 * @constructor
20 */
21function ImageEditor(
22    viewport, imageView, prompt, DOMContainers, modes, displayStringFunction,
23    onToolsVisibilityChanged) {
24  this.rootContainer_ = DOMContainers.root;
25  this.container_ = DOMContainers.image;
26  this.modes_ = modes;
27  this.displayStringFunction_ = displayStringFunction;
28  this.onToolsVisibilityChanged_ = onToolsVisibilityChanged;
29
30  ImageUtil.removeChildren(this.container_);
31
32  var document = this.container_.ownerDocument;
33
34  this.viewport_ = viewport;
35  this.viewport_.sizeByFrame(this.container_);
36
37  this.buffer_ = new ImageBuffer();
38  this.viewport_.addRepaintCallback(this.buffer_.draw.bind(this.buffer_));
39
40  this.imageView_ = imageView;
41  this.imageView_.addContentCallback(this.onContentUpdate_.bind(this));
42  this.buffer_.addOverlay(this.imageView_);
43
44  this.panControl_ = new ImageEditor.MouseControl(
45      this.rootContainer_, this.container_, this.getBuffer());
46
47  this.panControl_.setDoubleTapCallback(this.onDoubleTap_.bind(this));
48
49  this.mainToolbar_ = new ImageEditor.Toolbar(
50      DOMContainers.toolbar, displayStringFunction);
51
52  this.modeToolbar_ = new ImageEditor.Toolbar(
53      DOMContainers.mode, displayStringFunction,
54      this.onOptionsChange.bind(this));
55
56  this.prompt_ = prompt;
57
58  this.createToolButtons();
59
60  this.commandQueue_ = null;
61}
62
63/**
64 * @return {boolean} True if no user commands are to be accepted.
65 */
66ImageEditor.prototype.isLocked = function() {
67  return !this.commandQueue_ || this.commandQueue_.isBusy();
68};
69
70/**
71 * @return {boolean} True if the command queue is busy.
72 */
73ImageEditor.prototype.isBusy = function() {
74  return this.commandQueue_ && this.commandQueue_.isBusy();
75};
76
77/**
78 * Reflect the locked state of the editor in the UI.
79 * @param {boolean} on True if locked.
80 */
81ImageEditor.prototype.lockUI = function(on) {
82  ImageUtil.setAttribute(this.rootContainer_, 'locked', on);
83};
84
85/**
86 * Report the tool use to the metrics subsystem.
87 * @param {string} name Action name.
88 */
89ImageEditor.prototype.recordToolUse = function(name) {
90  ImageUtil.metrics.recordEnum(
91      ImageUtil.getMetricName('Tool'), name, this.actionNames_);
92};
93
94/**
95 * Content update handler.
96 * @private
97 */
98ImageEditor.prototype.onContentUpdate_ = function() {
99  for (var i = 0; i != this.modes_.length; i++) {
100    var mode = this.modes_[i];
101    ImageUtil.setAttribute(mode.button_, 'disabled', !mode.isApplicable());
102  }
103};
104
105/**
106 * Open the editing session for a new image.
107 *
108 * @param {string} url Image url.
109 * @param {Object} metadata Metadata.
110 * @param {Object} effect Transition effect object.
111 * @param {function(function)} saveFunction Image save function.
112 * @param {function} displayCallback Display callback.
113 * @param {function} loadCallback Load callback.
114 */
115ImageEditor.prototype.openSession = function(
116    url, metadata, effect, saveFunction, displayCallback, loadCallback) {
117  if (this.commandQueue_)
118    throw new Error('Session not closed');
119
120  this.lockUI(true);
121
122  var self = this;
123  this.imageView_.load(
124      url, metadata, effect, displayCallback, function(loadType, delay, error) {
125    self.lockUI(false);
126    self.commandQueue_ = new CommandQueue(
127        self.container_.ownerDocument,
128        self.imageView_.getCanvas(),
129        saveFunction);
130    self.commandQueue_.attachUI(
131        self.getImageView(), self.getPrompt(), self.lockUI.bind(self));
132    self.updateUndoRedo();
133    loadCallback(loadType, delay, error);
134  });
135};
136
137/**
138 * Close the current image editing session.
139 * @param {function} callback Callback.
140 */
141ImageEditor.prototype.closeSession = function(callback) {
142  this.getPrompt().hide();
143  if (this.imageView_.isLoading()) {
144    if (this.commandQueue_) {
145      console.warn('Inconsistent image editor state');
146      this.commandQueue_ = null;
147    }
148    this.imageView_.cancelLoad();
149    this.lockUI(false);
150    callback();
151    return;
152  }
153  if (!this.commandQueue_) {
154    // Session is already closed.
155    callback();
156    return;
157  }
158
159  this.executeWhenReady(callback);
160  this.commandQueue_.close();
161  this.commandQueue_ = null;
162};
163
164/**
165 * Commit the current operation and execute the action.
166 *
167 * @param {function} callback Callback.
168 */
169ImageEditor.prototype.executeWhenReady = function(callback) {
170  if (this.commandQueue_) {
171    this.leaveModeGently();
172    this.commandQueue_.executeWhenReady(callback);
173  } else {
174    if (!this.imageView_.isLoading())
175      console.warn('Inconsistent image editor state');
176    callback();
177  }
178};
179
180/**
181 * @return {boolean} True if undo queue is not empty.
182 */
183ImageEditor.prototype.canUndo = function() {
184  return this.commandQueue_ && this.commandQueue_.canUndo();
185};
186
187/**
188 * Undo the recently executed command.
189 */
190ImageEditor.prototype.undo = function() {
191  if (this.isLocked()) return;
192  this.recordToolUse('undo');
193
194  // First undo click should dismiss the uncommitted modifications.
195  if (this.currentMode_ && this.currentMode_.isUpdated()) {
196    this.currentMode_.reset();
197    return;
198  }
199
200  this.getPrompt().hide();
201  this.leaveMode(false);
202  this.commandQueue_.undo();
203  this.updateUndoRedo();
204};
205
206/**
207 * Redo the recently un-done command.
208 */
209ImageEditor.prototype.redo = function() {
210  if (this.isLocked()) return;
211  this.recordToolUse('redo');
212  this.getPrompt().hide();
213  this.leaveMode(false);
214  this.commandQueue_.redo();
215  this.updateUndoRedo();
216};
217
218/**
219 * Update Undo/Redo buttons state.
220 */
221ImageEditor.prototype.updateUndoRedo = function() {
222  var canUndo = this.commandQueue_ && this.commandQueue_.canUndo();
223  var canRedo = this.commandQueue_ && this.commandQueue_.canRedo();
224  ImageUtil.setAttribute(this.undoButton_, 'disabled', !canUndo);
225  this.redoButton_.hidden = !canRedo;
226};
227
228/**
229 * @return {HTMLCanvasElement} The current image canvas.
230 */
231ImageEditor.prototype.getCanvas = function() {
232  return this.getImageView().getCanvas();
233};
234
235/**
236 * @return {ImageBuffer} ImageBuffer instance.
237 */
238ImageEditor.prototype.getBuffer = function() { return this.buffer_ };
239
240/**
241 * @return {ImageView} ImageView instance.
242 */
243ImageEditor.prototype.getImageView = function() { return this.imageView_ };
244
245/**
246 * @return {Viewport} Viewport instance.
247 */
248ImageEditor.prototype.getViewport = function() { return this.viewport_ };
249
250/**
251 * @return {ImageEditor.Prompt} Prompt instance.
252 */
253ImageEditor.prototype.getPrompt = function() { return this.prompt_ };
254
255/**
256 * Handle the toolbar controls update.
257 * @param {Object} options A map of options.
258 */
259ImageEditor.prototype.onOptionsChange = function(options) {
260  ImageUtil.trace.resetTimer('update');
261  if (this.currentMode_) {
262    this.currentMode_.update(options);
263  }
264  ImageUtil.trace.reportTimer('update');
265};
266
267/**
268 * ImageEditor.Mode represents a modal state dedicated to a specific operation.
269 * Inherits from ImageBuffer. Overlay to simplify the drawing of mode-specific
270 * tools.
271 *
272 * @param {string} name The mode name.
273 * @param {string} title The mode title.
274 * @constructor
275 */
276
277ImageEditor.Mode = function(name, title) {
278  this.name = name;
279  this.title = title;
280  this.message_ = 'GALLERY_ENTER_WHEN_DONE';
281};
282
283ImageEditor.Mode.prototype = {__proto__: ImageBuffer.Overlay.prototype };
284
285/**
286 * @return {Viewport} Viewport instance.
287 */
288ImageEditor.Mode.prototype.getViewport = function() { return this.viewport_ };
289
290/**
291 * @return {ImageView} ImageView instance.
292 */
293ImageEditor.Mode.prototype.getImageView = function() { return this.imageView_ };
294
295/**
296 * @return {string} The mode-specific message to be displayed when entering.
297 */
298ImageEditor.Mode.prototype.getMessage = function() { return this.message_ };
299
300/**
301 * @return {boolean} True if the mode is applicable in the current context.
302 */
303ImageEditor.Mode.prototype.isApplicable = function() { return true };
304
305/**
306 * Called once after creating the mode button.
307 *
308 * @param {ImageEditor} editor The editor instance.
309 * @param {HTMLElement} button The mode button.
310 */
311
312ImageEditor.Mode.prototype.bind = function(editor, button) {
313  this.editor_ = editor;
314  this.editor_.registerAction_(this.name);
315  this.button_ = button;
316  this.viewport_ = editor.getViewport();
317  this.imageView_ = editor.getImageView();
318};
319
320/**
321 * Called before entering the mode.
322 */
323ImageEditor.Mode.prototype.setUp = function() {
324  this.editor_.getBuffer().addOverlay(this);
325  this.updated_ = false;
326};
327
328/**
329 * Create mode-specific controls here.
330 * @param {ImageEditor.Toolbar} toolbar The toolbar to populate.
331 */
332ImageEditor.Mode.prototype.createTools = function(toolbar) {};
333
334/**
335 * Called before exiting the mode.
336 */
337ImageEditor.Mode.prototype.cleanUpUI = function() {
338  this.editor_.getBuffer().removeOverlay(this);
339};
340
341/**
342 * Called after exiting the mode.
343 */
344ImageEditor.Mode.prototype.cleanUpCaches = function() {};
345
346/**
347 * Called when any of the controls changed its value.
348 * @param {Object} options A map of options.
349 */
350ImageEditor.Mode.prototype.update = function(options) {
351  this.markUpdated();
352};
353
354/**
355 * Mark the editor mode as updated.
356 */
357ImageEditor.Mode.prototype.markUpdated = function() {
358  this.updated_ = true;
359};
360
361/**
362 * @return {boolean} True if the mode controls changed.
363 */
364ImageEditor.Mode.prototype.isUpdated = function() { return this.updated_ };
365
366/**
367 * Resets the mode to a clean state.
368 */
369ImageEditor.Mode.prototype.reset = function() {
370  this.editor_.modeToolbar_.reset();
371  this.updated_ = false;
372};
373
374/**
375 * One-click editor tool, requires no interaction, just executes the command.
376 *
377 * @param {string} name The mode name.
378 * @param {string} title The mode title.
379 * @param {Command} command The command to execute on click.
380 * @constructor
381 */
382ImageEditor.Mode.OneClick = function(name, title, command) {
383  ImageEditor.Mode.call(this, name, title);
384  this.instant = true;
385  this.command_ = command;
386};
387
388ImageEditor.Mode.OneClick.prototype = {__proto__: ImageEditor.Mode.prototype};
389
390/**
391 * @return {Command} command.
392 */
393ImageEditor.Mode.OneClick.prototype.getCommand = function() {
394  return this.command_;
395};
396
397/**
398 * Register the action name. Required for metrics reporting.
399 * @param {string} name Button name.
400 * @private
401 */
402ImageEditor.prototype.registerAction_ = function(name) {
403  this.actionNames_.push(name);
404};
405
406/**
407 * Populate the toolbar.
408 */
409ImageEditor.prototype.createToolButtons = function() {
410  this.mainToolbar_.clear();
411  this.actionNames_ = [];
412
413  var self = this;
414  function createButton(name, title, handler) {
415    return self.mainToolbar_.addButton(name,
416                                       title,
417                                       handler,
418                                       name /* opt_className */);
419  }
420
421  for (var i = 0; i != this.modes_.length; i++) {
422    var mode = this.modes_[i];
423    mode.bind(this, createButton(mode.name,
424                                 mode.title,
425                                 this.enterMode.bind(this, mode)));
426  }
427
428  this.undoButton_ = createButton('undo',
429                                  'GALLERY_UNDO',
430                                  this.undo.bind(this));
431  this.registerAction_('undo');
432
433  this.redoButton_ = createButton('redo',
434                                  'GALLERY_REDO',
435                                  this.redo.bind(this));
436  this.registerAction_('redo');
437};
438
439/**
440 * @return {ImageEditor.Mode} The current mode.
441 */
442ImageEditor.prototype.getMode = function() { return this.currentMode_ };
443
444/**
445 * The user clicked on the mode button.
446 *
447 * @param {ImageEditor.Mode} mode The new mode.
448 */
449ImageEditor.prototype.enterMode = function(mode) {
450  if (this.isLocked()) return;
451
452  if (this.currentMode_ == mode) {
453    // Currently active editor tool clicked, commit if modified.
454    this.leaveMode(this.currentMode_.updated_);
455    return;
456  }
457
458  this.recordToolUse(mode.name);
459
460  this.leaveModeGently();
461  // The above call could have caused a commit which might have initiated
462  // an asynchronous command execution. Wait for it to complete, then proceed
463  // with the mode set up.
464  this.commandQueue_.executeWhenReady(this.setUpMode_.bind(this, mode));
465};
466
467/**
468 * Set up the new editing mode.
469 *
470 * @param {ImageEditor.Mode} mode The mode.
471 * @private
472 */
473ImageEditor.prototype.setUpMode_ = function(mode) {
474  this.currentTool_ = mode.button_;
475
476  ImageUtil.setAttribute(this.currentTool_, 'pressed', true);
477
478  this.currentMode_ = mode;
479  this.currentMode_.setUp();
480
481  if (this.currentMode_.instant) {  // Instant tool.
482    this.leaveMode(true);
483    return;
484  }
485
486  this.getPrompt().show(this.currentMode_.getMessage());
487
488  this.modeToolbar_.clear();
489  this.currentMode_.createTools(this.modeToolbar_);
490  this.modeToolbar_.show(true);
491};
492
493/**
494 * The user clicked on 'OK' or 'Cancel' or on a different mode button.
495 * @param {boolean} commit True if commit is required.
496 */
497ImageEditor.prototype.leaveMode = function(commit) {
498  if (!this.currentMode_) return;
499
500  if (!this.currentMode_.instant) {
501    this.getPrompt().hide();
502  }
503
504  this.modeToolbar_.show(false);
505
506  this.currentMode_.cleanUpUI();
507  if (commit) {
508    var self = this;
509    var command = this.currentMode_.getCommand();
510    if (command) {  // Could be null if the user did not do anything.
511      this.commandQueue_.execute(command);
512      this.updateUndoRedo();
513    }
514  }
515  this.currentMode_.cleanUpCaches();
516  this.currentMode_ = null;
517
518  ImageUtil.setAttribute(this.currentTool_, 'pressed', false);
519  this.currentTool_ = null;
520};
521
522/**
523 * Leave the mode, commit only if required by the current mode.
524 */
525ImageEditor.prototype.leaveModeGently = function() {
526  this.leaveMode(this.currentMode_ &&
527                 this.currentMode_.updated_ &&
528                 this.currentMode_.implicitCommit);
529};
530
531/**
532 * Enter the editor mode with the given name.
533 *
534 * @param {string} name Mode name.
535 * @private
536 */
537ImageEditor.prototype.enterModeByName_ = function(name) {
538  for (var i = 0; i != this.modes_.length; i++) {
539    var mode = this.modes_[i];
540    if (mode.name == name) {
541      if (!mode.button_.hasAttribute('disabled'))
542        this.enterMode(mode);
543      return;
544    }
545  }
546  console.error('Mode "' + name + '" not found.');
547};
548
549/**
550 * Key down handler.
551 * @param {Event} event The keydown event.
552 * @return {boolean} True if handled.
553 */
554ImageEditor.prototype.onKeyDown = function(event) {
555  switch (util.getKeyModifiers(event) + event.keyIdentifier) {
556    case 'U+001B': // Escape
557    case 'Enter':
558      if (this.getMode()) {
559        this.leaveMode(event.keyIdentifier == 'Enter');
560        return true;
561      }
562      break;
563
564    case 'Ctrl-U+005A':  // Ctrl+Z
565      if (this.commandQueue_.canUndo()) {
566        this.undo();
567        return true;
568      }
569      break;
570
571    case 'Ctrl-U+0059':  // Ctrl+Y
572      if (this.commandQueue_.canRedo()) {
573        this.redo();
574        return true;
575      }
576      break;
577
578    case 'U+0041':  // 'a'
579      this.enterModeByName_('autofix');
580      return true;
581
582    case 'U+0042':  // 'b'
583      this.enterModeByName_('exposure');
584      return true;
585
586    case 'U+0043':  // 'c'
587      this.enterModeByName_('crop');
588      return true;
589
590    case 'U+004C':  // 'l'
591      this.enterModeByName_('rotate_left');
592      return true;
593
594    case 'U+0052':  // 'r'
595      this.enterModeByName_('rotate_right');
596      return true;
597  }
598  return false;
599};
600
601/**
602 * Double tap handler.
603 * @param {number} x X coordinate of the event.
604 * @param {number} y Y coordinate of the event.
605 * @private
606 */
607ImageEditor.prototype.onDoubleTap_ = function(x, y) {
608  if (this.getMode()) {
609    var action = this.buffer_.getDoubleTapAction(x, y);
610    if (action == ImageBuffer.DoubleTapAction.COMMIT)
611      this.leaveMode(true);
612    else if (action == ImageBuffer.DoubleTapAction.CANCEL)
613      this.leaveMode(false);
614  }
615};
616
617/**
618 * Hide the tools that overlap the given rectangular frame.
619 *
620 * @param {Rect} frame Hide the tool that overlaps this rect.
621 * @param {Rect} transparent But do not hide the tool that is completely inside
622 *                           this rect.
623 */
624ImageEditor.prototype.hideOverlappingTools = function(frame, transparent) {
625  var tools = this.rootContainer_.ownerDocument.querySelectorAll('.dimmable');
626  var changed = false;
627  for (var i = 0; i != tools.length; i++) {
628    var tool = tools[i];
629    var toolRect = tool.getBoundingClientRect();
630    var overlapping =
631        (frame && frame.intersects(toolRect)) &&
632        !(transparent && transparent.contains(toolRect));
633    if (overlapping && !tool.hasAttribute('dimmed') ||
634        !overlapping && tool.hasAttribute('dimmed')) {
635      ImageUtil.setAttribute(tool, 'dimmed', overlapping);
636      changed = true;
637    }
638  }
639  if (changed)
640    this.onToolsVisibilityChanged_();
641};
642
643/**
644 * A helper object for panning the ImageBuffer.
645 *
646 * @param {HTMLElement} rootContainer The top-level container.
647 * @param {HTMLElement} container The container for mouse events.
648 * @param {ImageBuffer} buffer Image buffer.
649 * @constructor
650 */
651ImageEditor.MouseControl = function(rootContainer, container, buffer) {
652  this.rootContainer_ = rootContainer;
653  this.container_ = container;
654  this.buffer_ = buffer;
655
656  var handlers = {
657    'touchstart': this.onTouchStart,
658    'touchend': this.onTouchEnd,
659    'touchcancel': this.onTouchCancel,
660    'touchmove': this.onTouchMove,
661    'mousedown': this.onMouseDown,
662    'mouseup': this.onMouseUp
663  };
664
665  for (var eventName in handlers) {
666    container.addEventListener(
667        eventName, handlers[eventName].bind(this), false);
668  }
669
670  // Mouse move handler has to be attached to the window to receive events
671  // from outside of the window. See: http://crbug.com/155705
672  window.addEventListener('mousemove', this.onMouseMove.bind(this), false);
673};
674
675/**
676 * Maximum movement for touch to be detected as a tap (in pixels).
677 * @private
678 */
679ImageEditor.MouseControl.MAX_MOVEMENT_FOR_TAP_ = 8;
680
681/**
682 * Maximum time for touch to be detected as a tap (in milliseconds).
683 * @private
684 */
685ImageEditor.MouseControl.MAX_TAP_DURATION_ = 500;
686
687/**
688 * Maximum distance from the first tap to the second tap to be considered
689 * as a double tap.
690 * @private
691 */
692ImageEditor.MouseControl.MAX_DISTANCE_FOR_DOUBLE_TAP_ = 32;
693
694/**
695 * Maximum time for touch to be detected as a double tap (in milliseconds).
696 * @private
697 */
698ImageEditor.MouseControl.MAX_DOUBLE_TAP_DURATION_ = 1000;
699
700/**
701 * Returns an event's position.
702 *
703 * @param {MouseEvent|Touch} e Pointer position.
704 * @return {Object} A pair of x,y in page coordinates.
705 * @private
706 */
707ImageEditor.MouseControl.getPosition_ = function(e) {
708  return {
709    x: e.pageX,
710    y: e.pageY
711  };
712};
713
714/**
715 * Returns touch position or null if there is more than one touch position.
716 *
717 * @param {TouchEvent} e Event.
718 * @return {object?} A pair of x,y in page coordinates.
719 * @private
720 */
721ImageEditor.MouseControl.prototype.getTouchPosition_ = function(e) {
722  if (e.targetTouches.length == 1)
723    return ImageEditor.MouseControl.getPosition_(e.targetTouches[0]);
724  else
725    return null;
726};
727
728/**
729 * Touch start handler.
730 * @param {TouchEvent} e Event.
731 */
732ImageEditor.MouseControl.prototype.onTouchStart = function(e) {
733  var position = this.getTouchPosition_(e);
734  if (position) {
735    this.touchStartInfo_ = {
736      x: position.x,
737      y: position.y,
738      time: Date.now()
739    };
740    this.dragHandler_ = this.buffer_.getDragHandler(position.x, position.y,
741                                                    true /* touch */);
742    this.dragHappened_ = false;
743    e.preventDefault();
744  }
745};
746
747/**
748 * Touch end handler.
749 * @param {TouchEvent} e Event.
750 */
751ImageEditor.MouseControl.prototype.onTouchEnd = function(e) {
752  if (!this.dragHappened_ && Date.now() - this.touchStartInfo_.time <=
753                             ImageEditor.MouseControl.MAX_TAP_DURATION_) {
754    this.buffer_.onClick(this.touchStartInfo_.x, this.touchStartInfo_.y);
755    if (this.previousTouchStartInfo_ &&
756        Date.now() - this.previousTouchStartInfo_.time <
757            ImageEditor.MouseControl.MAX_DOUBLE_TAP_DURATION_) {
758      var prevTouchCircle = new Circle(
759          this.previousTouchStartInfo_.x,
760          this.previousTouchStartInfo_.y,
761          ImageEditor.MouseControl.MAX_DISTANCE_FOR_DOUBLE_TAP_);
762      if (prevTouchCircle.inside(this.touchStartInfo_.x,
763                                 this.touchStartInfo_.y)) {
764        this.doubleTapCallback_(this.touchStartInfo_.x, this.touchStartInfo_.y);
765      }
766    }
767    this.previousTouchStartInfo_ = this.touchStartInfo_;
768  } else {
769    this.previousTouchStartInfo_ = null;
770  }
771  this.onTouchCancel(e);
772};
773
774/**
775 * Default double tap handler.
776 * @param {number} x X coordinate of the event.
777 * @param {number} y Y coordinate of the event.
778 * @private
779 */
780ImageEditor.MouseControl.prototype.doubleTapCallback_ = function(x, y) {};
781
782/**
783 * Sets callback to be called when double tap detected.
784 * @param {function(number, number)} callback New double tap callback.
785 */
786ImageEditor.MouseControl.prototype.setDoubleTapCallback = function(callback) {
787  this.doubleTapCallback_ = callback;
788};
789
790/**
791 * Touch cancel handler.
792 */
793ImageEditor.MouseControl.prototype.onTouchCancel = function() {
794  this.dragHandler_ = null;
795  this.dragHappened_ = false;
796  this.touchStartInfo_ = null;
797  this.lockMouse_(false);
798};
799
800/**
801 * Touch move handler.
802 * @param {TouchEvent} e Event.
803 */
804ImageEditor.MouseControl.prototype.onTouchMove = function(e) {
805  var position = this.getTouchPosition_(e);
806  if (!position)
807    return;
808
809  if (this.touchStartInfo_ && !this.dragHappened_) {
810    var tapCircle = new Circle(this.touchStartInfo_.x, this.touchStartInfo_.y,
811                    ImageEditor.MouseControl.MAX_MOVEMENT_FOR_TAP_);
812    this.dragHappened_ = !tapCircle.inside(position.x, position.y);
813  }
814  if (this.dragHandler_ && this.dragHappened_) {
815    this.dragHandler_(position.x, position.y);
816    this.lockMouse_(true);
817  }
818};
819
820/**
821 * Mouse down handler.
822 * @param {MouseEvent} e Event.
823 */
824ImageEditor.MouseControl.prototype.onMouseDown = function(e) {
825  var position = ImageEditor.MouseControl.getPosition_(e);
826
827  this.dragHandler_ = this.buffer_.getDragHandler(position.x, position.y,
828                                                  false /* mouse */);
829  this.dragHappened_ = false;
830  this.updateCursor_(position);
831};
832
833/**
834 * Mouse up handler.
835 * @param {MouseEvent} e Event.
836 */
837ImageEditor.MouseControl.prototype.onMouseUp = function(e) {
838  var position = ImageEditor.MouseControl.getPosition_(e);
839
840  if (!this.dragHappened_) {
841    this.buffer_.onClick(position.x, position.y);
842  }
843  this.dragHandler_ = null;
844  this.dragHappened_ = false;
845  this.lockMouse_(false);
846};
847
848/**
849 * Mouse move handler.
850 * @param {MouseEvent} e Event.
851 */
852ImageEditor.MouseControl.prototype.onMouseMove = function(e) {
853  var position = ImageEditor.MouseControl.getPosition_(e);
854
855  if (this.dragHandler_ && !e.which) {
856    // mouseup must have happened while the mouse was outside our window.
857    this.dragHandler_ = null;
858    this.lockMouse_(false);
859  }
860
861  this.updateCursor_(position);
862  if (this.dragHandler_) {
863    this.dragHandler_(position.x, position.y);
864    this.dragHappened_ = true;
865    this.lockMouse_(true);
866  }
867};
868
869/**
870 * Update the UI to reflect mouse drag state.
871 * @param {boolean} on True if dragging.
872 * @private
873 */
874ImageEditor.MouseControl.prototype.lockMouse_ = function(on) {
875  ImageUtil.setAttribute(this.rootContainer_, 'mousedrag', on);
876};
877
878/**
879 * Update the cursor.
880 *
881 * @param {Object} position An object holding x and y properties.
882 * @private
883 */
884ImageEditor.MouseControl.prototype.updateCursor_ = function(position) {
885  var oldCursor = this.container_.getAttribute('cursor');
886  var newCursor = this.buffer_.getCursorStyle(
887      position.x, position.y, !!this.dragHandler_);
888  if (newCursor != oldCursor)  // Avoid flicker.
889    this.container_.setAttribute('cursor', newCursor);
890};
891
892/**
893 * A toolbar for the ImageEditor.
894 * @param {HTMLElement} parent The parent element.
895 * @param {function} displayStringFunction A string formatting function.
896 * @param {function} updateCallback The callback called when controls change.
897 * @constructor
898 */
899ImageEditor.Toolbar = function(parent, displayStringFunction, updateCallback) {
900  this.wrapper_ = parent;
901  this.displayStringFunction_ = displayStringFunction;
902  this.updateCallback_ = updateCallback;
903};
904
905/**
906 * Clear the toolbar.
907 */
908ImageEditor.Toolbar.prototype.clear = function() {
909  ImageUtil.removeChildren(this.wrapper_);
910};
911
912/**
913 * Create a control.
914 * @param {string} tagName The element tag name.
915 * @return {HTMLElement} The created control element.
916 * @private
917 */
918ImageEditor.Toolbar.prototype.create_ = function(tagName) {
919  return this.wrapper_.ownerDocument.createElement(tagName);
920};
921
922/**
923 * Add a control.
924 * @param {HTMLElement} element The control to add.
925 * @return {HTMLElement} The added element.
926 */
927ImageEditor.Toolbar.prototype.add = function(element) {
928  this.wrapper_.appendChild(element);
929  return element;
930};
931
932/**
933 * Add a text label.
934 * @param {string} name Label name.
935 * @return {HTMLElement} The added label.
936 */
937ImageEditor.Toolbar.prototype.addLabel = function(name) {
938  var label = this.create_('span');
939  label.textContent = this.displayStringFunction_(name);
940  return this.add(label);
941};
942
943/**
944 * Add a button.
945 *
946 * @param {string} name Button name.
947 * @param {string} title Button title.
948 * @param {function} handler onClick handler.
949 * @param {string=} opt_class Extra class name.
950 * @return {HTMLElement} The added button.
951 */
952ImageEditor.Toolbar.prototype.addButton = function(
953    name, title, handler, opt_class) {
954  var button = this.create_('button');
955  if (opt_class) button.classList.add(opt_class);
956  var label = this.create_('span');
957  label.textContent = this.displayStringFunction_(title);
958  button.appendChild(label);
959  button.label = this.displayStringFunction_(title);
960  button.addEventListener('click', handler, false);
961  return this.add(button);
962};
963
964/**
965 * Add a range control (scalar value picker).
966 *
967 * @param {string} name An option name.
968 * @param {string} title An option title.
969 * @param {number} min Min value of the option.
970 * @param {number} value Default value of the option.
971 * @param {number} max Max value of the options.
972 * @param {number} scale A number to multiply by when setting
973 *                       min/value/max in DOM.
974 * @param {boolean=} opt_showNumeric True if numeric value should be displayed.
975 * @return {HTMLElement} Range element.
976 */
977ImageEditor.Toolbar.prototype.addRange = function(
978    name, title, min, value, max, scale, opt_showNumeric) {
979  var self = this;
980
981  scale = scale || 1;
982
983  var range = this.create_('input');
984
985  range.className = 'range';
986  range.type = 'range';
987  range.name = name;
988  range.min = Math.ceil(min * scale);
989  range.max = Math.floor(max * scale);
990
991  var numeric = this.create_('div');
992  numeric.className = 'numeric';
993  function mirror() {
994    numeric.textContent = Math.round(range.getValue() * scale) / scale;
995  }
996
997  range.setValue = function(newValue) {
998    range.value = Math.round(newValue * scale);
999    mirror();
1000  };
1001
1002  range.getValue = function() {
1003    return Number(range.value) / scale;
1004  };
1005
1006  range.reset = function() {
1007    range.setValue(value);
1008  };
1009
1010  range.addEventListener('change',
1011      function() {
1012        mirror();
1013        self.updateCallback_(self.getOptions());
1014      },
1015      false);
1016
1017  range.setValue(value);
1018
1019  var label = this.create_('div');
1020  label.textContent = this.displayStringFunction_(title);
1021  label.className = 'label ' + name;
1022  this.add(label);
1023  this.add(range);
1024
1025  if (opt_showNumeric)
1026    this.add(numeric);
1027
1028  // Swallow the left and right keys, so they are not handled by other
1029  // listeners.
1030  range.addEventListener('keydown', function(e) {
1031    if (e.keyIdentifier === 'Left' || e.keyIdentifier === 'Right')
1032      e.stopPropagation();
1033  });
1034
1035  return range;
1036};
1037
1038/**
1039 * @return {Object} options A map of options.
1040 */
1041ImageEditor.Toolbar.prototype.getOptions = function() {
1042  var values = {};
1043  for (var child = this.wrapper_.firstChild; child; child = child.nextSibling) {
1044    if (child.name)
1045      values[child.name] = child.getValue();
1046  }
1047  return values;
1048};
1049
1050/**
1051 * Reset the toolbar.
1052 */
1053ImageEditor.Toolbar.prototype.reset = function() {
1054  for (var child = this.wrapper_.firstChild; child; child = child.nextSibling) {
1055    if (child.reset) child.reset();
1056  }
1057};
1058
1059/**
1060 * Show/hide the toolbar.
1061 * @param {boolean} on True if show.
1062 */
1063ImageEditor.Toolbar.prototype.show = function(on) {
1064  if (!this.wrapper_.firstChild)
1065    return;  // Do not show empty toolbar;
1066
1067  this.wrapper_.hidden = !on;
1068};
1069
1070/** A prompt panel for the editor.
1071 *
1072 * @param {HTMLElement} container Container element.
1073 * @param {function} displayStringFunction A formatting function.
1074 * @constructor
1075 */
1076ImageEditor.Prompt = function(container, displayStringFunction) {
1077  this.container_ = container;
1078  this.displayStringFunction_ = displayStringFunction;
1079};
1080
1081/**
1082 * Reset the prompt.
1083 */
1084ImageEditor.Prompt.prototype.reset = function() {
1085  this.cancelTimer();
1086  if (this.wrapper_) {
1087    this.container_.removeChild(this.wrapper_);
1088    this.wrapper_ = null;
1089    this.prompt_ = null;
1090  }
1091};
1092
1093/**
1094 * Cancel the delayed action.
1095 */
1096ImageEditor.Prompt.prototype.cancelTimer = function() {
1097  if (this.timer_) {
1098    clearTimeout(this.timer_);
1099    this.timer_ = null;
1100  }
1101};
1102
1103/**
1104 * Schedule the delayed action.
1105 * @param {function} callback Callback.
1106 * @param {number} timeout Timeout.
1107 */
1108ImageEditor.Prompt.prototype.setTimer = function(callback, timeout) {
1109  this.cancelTimer();
1110  var self = this;
1111  this.timer_ = setTimeout(function() {
1112    self.timer_ = null;
1113    callback();
1114  }, timeout);
1115};
1116
1117/**
1118 * Show the prompt.
1119 *
1120 * @param {string} text The prompt text.
1121 * @param {number} timeout Timeout in ms.
1122 * @param {Object} formatArgs varArgs for the formatting function.
1123 */
1124ImageEditor.Prompt.prototype.show = function(text, timeout, formatArgs) {
1125  this.showAt.apply(this,
1126      ['center'].concat(Array.prototype.slice.call(arguments)));
1127};
1128
1129/**
1130 *
1131 * @param {string} pos The 'pos' attribute value.
1132 * @param {string} text The prompt text.
1133 * @param {number} timeout Timeout in ms.
1134 * @param {Object} formatArgs varArgs for the formatting function.
1135 */
1136ImageEditor.Prompt.prototype.showAt = function(pos, text, timeout, formatArgs) {
1137  this.reset();
1138  if (!text) return;
1139
1140  var document = this.container_.ownerDocument;
1141  this.wrapper_ = document.createElement('div');
1142  this.wrapper_.className = 'prompt-wrapper';
1143  this.wrapper_.setAttribute('pos', pos);
1144  this.container_.appendChild(this.wrapper_);
1145
1146  this.prompt_ = document.createElement('div');
1147  this.prompt_.className = 'prompt';
1148
1149  // Create an extra wrapper which opacity can be manipulated separately.
1150  var tool = document.createElement('div');
1151  tool.className = 'dimmable';
1152  this.wrapper_.appendChild(tool);
1153  tool.appendChild(this.prompt_);
1154
1155  var args = [text].concat(Array.prototype.slice.call(arguments, 3));
1156  this.prompt_.textContent = this.displayStringFunction_.apply(null, args);
1157
1158  var close = document.createElement('div');
1159  close.className = 'close';
1160  close.addEventListener('click', this.hide.bind(this));
1161  this.prompt_.appendChild(close);
1162
1163  setTimeout(
1164      this.prompt_.setAttribute.bind(this.prompt_, 'state', 'fadein'), 0);
1165
1166  if (timeout)
1167    this.setTimer(this.hide.bind(this), timeout);
1168};
1169
1170/**
1171 * Hide the prompt.
1172 */
1173ImageEditor.Prompt.prototype.hide = function() {
1174  if (!this.prompt_) return;
1175  this.prompt_.setAttribute('state', 'fadeout');
1176  // Allow some time for the animation to play out.
1177  this.setTimer(this.reset.bind(this), 500);
1178};
1179