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