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