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