blob: cee53ca437c8805a930ef30bb0aa69421cf6b3d7 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* ImageEditor is the top level object that holds together and connects
* everything needed for image editing.
*
* @param {!Viewport} viewport The viewport.
* @param {!ImageView} imageView The ImageView containing the images to edit.
* @param {!ImageEditor.Prompt} prompt Prompt instance.
* @param {!Object} DOMContainers Various DOM containers required for the
* editor.
* @param {!Array<!ImageEditor.Mode>} modes Available editor modes.
* @param {function(string, ...string)} displayStringFunction String
* formatting function.
* @constructor
* @extends {cr.EventTarget}
* @struct
*
* TODO(yawano): Remove displayStringFunction from arguments.
*/
function ImageEditor(
viewport, imageView, prompt, DOMContainers, modes, displayStringFunction) {
cr.EventTarget.call(this);
this.rootContainer_ = DOMContainers.root;
this.container_ = DOMContainers.image;
this.modes_ = modes;
this.displayStringFunction_ = displayStringFunction;
/**
* @private {ImageEditor.Mode}
*/
this.currentMode_ = null;
/**
* @private {HTMLElement}
*/
this.currentTool_ = null;
/**
* @private {boolean}
*/
this.settingUpNextMode_ = false;
ImageUtil.removeChildren(this.container_);
this.viewport_ = viewport;
this.imageView_ = imageView;
this.buffer_ = new ImageBuffer();
this.buffer_.addOverlay(this.imageView_);
this.panControl_ = new ImageEditor.MouseControl(
this.rootContainer_, this.container_, this.getBuffer());
this.panControl_.setDoubleTapCallback(this.onDoubleTap_.bind(this));
this.mainToolbar_ = new ImageEditor.Toolbar(
DOMContainers.toolbar, displayStringFunction);
this.modeToolbar_ = new ImageEditor.Toolbar(
DOMContainers.mode, displayStringFunction,
this.onOptionsChange.bind(this), true /* done button */);
this.modeToolbar_.addEventListener(
'done-clicked', this.onDoneClicked_.bind(this));
this.modeToolbar_.addEventListener(
'cancel-clicked', this.onCancelClicked_.bind(this));
this.prompt_ = prompt;
this.commandQueue_ = null;
// -----------------------------------------------------------------
// Populate the toolbar.
/**
* @type {!Array<string>}
* @private
*/
this.actionNames_ = [];
this.mainToolbar_.clear();
// Create action buttons.
for (var i = 0; i != this.modes_.length; i++) {
var mode = this.modes_[i];
mode.bind(this, this.createToolButton_(mode.name, mode.title,
this.enterMode.bind(this, mode),
mode.instant));
}
/**
* @type {!HTMLElement}
* @private
*/
this.undoButton_ = this.createToolButton_('undo', 'GALLERY_UNDO',
this.undo.bind(this),
true /* instant */);
this.registerAction_('undo');
/**
* @type {!HTMLElement}
* @private
*/
this.redoButton_ = this.createToolButton_('redo', 'GALLERY_REDO',
this.redo.bind(this),
true /* instant */);
this.registerAction_('redo');
/**
* @private {!HTMLElement}
* @const
*/
this.exitButton_ = /** @type {!HTMLElement} */
(queryRequiredElement('.edit-mode-toolbar paper-button.exit'));
this.exitButton_.addEventListener('click', this.onExitClicked_.bind(this));
/**
* @private {!FilesToast}
*/
this.filesToast_ = /** @type {!FilesToast}*/
(queryRequiredElement('files-toast'));
}
ImageEditor.prototype.__proto__ = cr.EventTarget.prototype;
/**
* Handles click event of exit button.
* @private
*/
ImageEditor.prototype.onExitClicked_ = function() {
var event = new Event('exit-clicked');
this.dispatchEvent(event);
};
/**
* Creates a toolbar button.
* @param {string} name Button name.
* @param {string} title Button title.
* @param {function(Event)} handler onClick handler.
* @param {boolean} isInstant True if this tool (mode) is instant.
* @return {!HTMLElement} A created button.
* @private
*/
ImageEditor.prototype.createToolButton_ = function(
name, title, handler, isInstant) {
var button = this.mainToolbar_.addButton(
title,
isInstant ? ImageEditor.Toolbar.ButtonType.ICON :
ImageEditor.Toolbar.ButtonType.ICON_TOGGLEABLE,
handler,
name /* opt_className */);
return button;
};
/**
* @return {boolean} True if no user commands are to be accepted.
*/
ImageEditor.prototype.isLocked = function() {
return !this.commandQueue_ || this.commandQueue_.isBusy();
};
/**
* @return {boolean} True if the command queue is busy.
*/
ImageEditor.prototype.isBusy = function() {
return this.commandQueue_ && this.commandQueue_.isBusy();
};
/**
* Reflect the locked state of the editor in the UI.
* @param {boolean} on True if locked.
*/
ImageEditor.prototype.lockUI = function(on) {
ImageUtil.setAttribute(this.rootContainer_, 'locked', on);
};
/**
* Report the tool use to the metrics subsystem.
* @param {string} name Action name.
*/
ImageEditor.prototype.recordToolUse = function(name) {
ImageUtil.metrics.recordEnum(
ImageUtil.getMetricName('Tool'), name, this.actionNames_);
};
/**
* Content update handler.
* @private
*/
ImageEditor.prototype.calculateModeApplicativity_ = function() {
for (var i = 0; i != this.modes_.length; i++) {
var mode = this.modes_[i];
ImageUtil.setAttribute(assert(mode.button_), 'disabled',
!mode.isApplicable());
}
};
/**
* Open the editing session for a new image.
*
* @param {!GalleryItem} item Gallery item.
* @param {!ImageView.Effect} effect Transition effect object.
* @param {function(function())} saveFunction Image save function.
* @param {function()} displayCallback Display callback.
* @param {function(!ImageView.LoadType, number, *=)} loadCallback Load
* callback.
*/
ImageEditor.prototype.openSession = function(
item, effect, saveFunction, displayCallback, loadCallback) {
if (this.commandQueue_)
throw new Error('Session not closed');
this.lockUI(true);
var self = this;
this.imageView_.load(
item, effect, displayCallback, function(loadType, delay, error) {
self.lockUI(false);
// Always handle an item as original for new session.
item.setAsOriginal();
self.commandQueue_ = new CommandQueue(
self.container_.ownerDocument, assert(self.imageView_.getImage()),
saveFunction);
self.commandQueue_.attachUI(
self.getImageView(), self.getPrompt(), self.filesToast_,
self.updateUndoRedo.bind(self), self.lockUI.bind(self));
self.updateUndoRedo();
loadCallback(loadType, delay, error);
});
};
/**
* Close the current image editing session.
* @param {function()} callback Callback.
*/
ImageEditor.prototype.closeSession = function(callback) {
this.getPrompt().hide();
if (this.imageView_.isLoading()) {
if (this.commandQueue_) {
console.warn('Inconsistent image editor state');
this.commandQueue_ = null;
}
this.imageView_.cancelLoad();
this.lockUI(false);
callback();
return;
}
if (!this.commandQueue_) {
// Session is already closed.
callback();
return;
}
this.executeWhenReady(callback);
this.commandQueue_.close();
this.commandQueue_ = null;
};
/**
* Commit the current operation and execute the action.
*
* @param {function()} callback Callback.
*/
ImageEditor.prototype.executeWhenReady = function(callback) {
if (this.commandQueue_) {
this.leaveMode(false /* not to switch mode */);
this.commandQueue_.executeWhenReady(callback);
} else {
if (!this.imageView_.isLoading())
console.warn('Inconsistent image editor state');
callback();
}
};
/**
* @return {boolean} True if undo queue is not empty.
*/
ImageEditor.prototype.canUndo = function() {
return !!this.commandQueue_ && this.commandQueue_.canUndo();
};
/**
* Undo the recently executed command.
*/
ImageEditor.prototype.undo = function() {
if (this.isLocked()) return;
this.recordToolUse('undo');
// First undo click should dismiss the uncommitted modifications.
if (this.currentMode_ && this.currentMode_.isUpdated()) {
this.currentMode_.reset();
return;
}
this.getPrompt().hide();
this.leaveModeInternal_(false, false /* not to switch mode */);
this.commandQueue_.undo();
this.updateUndoRedo();
this.calculateModeApplicativity_();
};
/**
* Redo the recently un-done command.
*/
ImageEditor.prototype.redo = function() {
if (this.isLocked()) return;
this.recordToolUse('redo');
this.getPrompt().hide();
this.leaveModeInternal_(false, false /* not to switch mode */);
this.commandQueue_.redo();
this.updateUndoRedo();
this.calculateModeApplicativity_();
};
/**
* Update Undo/Redo buttons state.
*/
ImageEditor.prototype.updateUndoRedo = function() {
var canUndo = this.commandQueue_ && this.commandQueue_.canUndo();
var canRedo = this.commandQueue_ && this.commandQueue_.canRedo();
ImageUtil.setAttribute(this.undoButton_, 'disabled', !canUndo);
ImageUtil.setAttribute(this.redoButton_, 'disabled', !canRedo);
};
/**
* @return {HTMLCanvasElement|HTMLImageElement} The current image.
*/
ImageEditor.prototype.getImage = function() {
return this.getImageView().getImage();
};
/**
* @return {!ImageBuffer} ImageBuffer instance.
*/
ImageEditor.prototype.getBuffer = function() { return this.buffer_; };
/**
* @return {!ImageView} ImageView instance.
*/
ImageEditor.prototype.getImageView = function() { return this.imageView_; };
/**
* @return {!Viewport} Viewport instance.
*/
ImageEditor.prototype.getViewport = function() { return this.viewport_; };
/**
* @return {!ImageEditor.Prompt} Prompt instance.
*/
ImageEditor.prototype.getPrompt = function() { return this.prompt_; };
/**
* Handle the toolbar controls update.
* @param {Object} options A map of options.
*/
ImageEditor.prototype.onOptionsChange = function(options) {
ImageUtil.trace.resetTimer('update');
if (this.currentMode_) {
this.currentMode_.update(options);
}
ImageUtil.trace.reportTimer('update');
};
/**
* ImageEditor.Mode represents a modal state dedicated to a specific operation.
* Inherits from ImageBuffer. Overlay to simplify the drawing of mode-specific
* tools.
*
* @param {string} name The mode name.
* @param {string} title The mode title.
* @constructor
* @struct
* @extends {ImageBuffer.Overlay}
*/
ImageEditor.Mode = function(name, title) {
this.name = name;
this.title = title;
this.message_ = 'GALLERY_ENTER_WHEN_DONE';
/**
* @type {boolean}
*/
this.implicitCommit = false;
/**
* @type {boolean}
*/
this.instant = false;
/**
* @type {number}
*/
this.paddingTop = 0;
/**
* @type {number}
*/
this.paddingBottom = 0;
/**
* @type {ImageEditor}
* @private
*/
this.editor_ = null;
/**
* @type {Viewport}
* @private
*/
this.viewport_ = null;
/**
* @type {HTMLElement}
* @private
*/
this.button_ = null;
/**
* @type {boolean}
* @private
*/
this.updated_ = false;
/**
* @type {ImageView}
* @private
*/
this.imageView_ = null;
};
ImageEditor.Mode.prototype = { __proto__: ImageBuffer.Overlay.prototype };
/**
* @return {Viewport} Viewport instance.
*/
ImageEditor.Mode.prototype.getViewport = function() { return this.viewport_; };
/**
* @return {ImageView} ImageView instance.
*/
ImageEditor.Mode.prototype.getImageView = function() {
return this.imageView_;
};
/**
* @return {string} The mode-specific message to be displayed when entering.
*/
ImageEditor.Mode.prototype.getMessage = function() { return this.message_; };
/**
* @return {boolean} True if the mode is applicable in the current context.
*/
ImageEditor.Mode.prototype.isApplicable = function() { return true; };
/**
* Called once after creating the mode button.
*
* @param {!ImageEditor} editor The editor instance.
* @param {!HTMLElement} button The mode button.
*/
ImageEditor.Mode.prototype.bind = function(editor, button) {
this.editor_ = editor;
this.editor_.registerAction_(this.name);
this.button_ = button;
this.viewport_ = editor.getViewport();
this.imageView_ = editor.getImageView();
};
/**
* Called before entering the mode.
*/
ImageEditor.Mode.prototype.setUp = function() {
this.editor_.getBuffer().addOverlay(this);
this.updated_ = false;
};
/**
* Create mode-specific controls here.
* @param {!ImageEditor.Toolbar} toolbar The toolbar to populate.
*/
ImageEditor.Mode.prototype.createTools = function(toolbar) {};
/**
* Called before exiting the mode.
*/
ImageEditor.Mode.prototype.cleanUpUI = function() {
this.editor_.getBuffer().removeOverlay(this);
};
/**
* Called after exiting the mode.
*/
ImageEditor.Mode.prototype.cleanUpCaches = function() {};
/**
* Called when any of the controls changed its value.
* @param {Object} options A map of options.
*/
ImageEditor.Mode.prototype.update = function(options) {
this.markUpdated();
};
/**
* Mark the editor mode as updated.
*/
ImageEditor.Mode.prototype.markUpdated = function() {
this.updated_ = true;
};
/**
* @return {boolean} True if the mode controls changed.
*/
ImageEditor.Mode.prototype.isUpdated = function() { return this.updated_; };
/**
* @return {boolean} True if a key event should be consumed by the mode.
*/
ImageEditor.Mode.prototype.isConsumingKeyEvents = function() { return false; };
/**
* Resets the mode to a clean state.
*/
ImageEditor.Mode.prototype.reset = function() {
this.editor_.modeToolbar_.reset();
this.updated_ = false;
};
/**
* @return {Command} Command.
*/
ImageEditor.Mode.prototype.getCommand = function() {
return null;
};
/**
* One-click editor tool, requires no interaction, just executes the command.
*
* @param {string} name The mode name.
* @param {string} title The mode title.
* @param {!Command} command The command to execute on click.
* @constructor
* @extends {ImageEditor.Mode}
* @struct
*/
ImageEditor.Mode.OneClick = function(name, title, command) {
ImageEditor.Mode.call(this, name, title);
this.instant = true;
this.command_ = command;
};
ImageEditor.Mode.OneClick.prototype = {__proto__: ImageEditor.Mode.prototype};
/**
* @override
*/
ImageEditor.Mode.OneClick.prototype.getCommand = function() {
return this.command_;
};
/**
* Register the action name. Required for metrics reporting.
* @param {string} name Button name.
* @private
*/
ImageEditor.prototype.registerAction_ = function(name) {
this.actionNames_.push(name);
};
/**
* @return {ImageEditor.Mode} The current mode.
*/
ImageEditor.prototype.getMode = function() { return this.currentMode_; };
/**
* The user clicked on the mode button.
*
* @param {!ImageEditor.Mode} mode The new mode.
*/
ImageEditor.prototype.enterMode = function(mode) {
if (this.isLocked()) return;
if (this.currentMode_ === mode) {
// Currently active editor tool clicked, commit if modified.
this.leaveModeInternal_(
this.currentMode_.updated_, false /* not to switch mode */);
return;
}
// Guard not to call setUpMode_ more than once.
if (this.settingUpNextMode_)
return;
this.settingUpNextMode_ = true;
this.recordToolUse(mode.name);
this.leaveMode(true /* to switch mode */);
// The above call could have caused a commit which might have initiated
// an asynchronous command execution. Wait for it to complete, then proceed
// with the mode set up.
this.commandQueue_.executeWhenReady(function() {
this.setUpMode_(mode);
this.settingUpNextMode_ = false;
}.bind(this));
};
/**
* Set up the new editing mode.
*
* @param {!ImageEditor.Mode} mode The mode.
* @private
*/
ImageEditor.prototype.setUpMode_ = function(mode) {
this.currentTool_ = mode.button_;
this.currentMode_ = mode;
this.rootContainer_.setAttribute('editor-mode', mode.name);
// Activate toggle ripple if button is toggleable.
var filesToggleRipple =
this.currentTool_.querySelector('files-toggle-ripple');
if (filesToggleRipple) {
// Current mode must NOT be instant for toggleable button.
assert(!this.currentMode_.instant);
filesToggleRipple.activated = true;
}
// Scale the screen so that it doesn't overlap the toolbars. We should scale
// the screen before setup of current mode is called to make the current mode
// able to set up with new screen size.
if (!this.currentMode_.instant) {
this.getViewport().setScreenTop(
ImageEditor.Toolbar.HEIGHT + mode.paddingTop);
this.getViewport().setScreenBottom(
ImageEditor.Toolbar.HEIGHT * 2 + mode.paddingBottom);
this.getImageView().applyViewportChange();
}
this.currentMode_.setUp();
this.calculateModeApplicativity_();
if (this.currentMode_.instant) { // Instant tool.
this.leaveModeInternal_(true, false /* not to switch mode */);
return;
}
this.exitButton_.hidden = true;
this.modeToolbar_.clear();
this.currentMode_.createTools(this.modeToolbar_);
this.modeToolbar_.show(true);
};
/**
* Handles click event of Done button.
* @param {!Event} event An event.
* @private
*/
ImageEditor.prototype.onDoneClicked_ = function(event) {
this.leaveModeInternal_(true /* commit */, false /* not to switch mode */);
};
/**
* Handles click event of Cancel button.
* @param {!Event} event An event.
* @private
*/
ImageEditor.prototype.onCancelClicked_ = function(event) {
this.leaveModeInternal_(
false /* not commit */, false /* not to switch mode */);
};
/**
* The user clicked on 'OK' or 'Cancel' or on a different mode button.
* @param {boolean} commit True if commit is required.
* @param {boolean} leaveToSwitchMode True if it leaves to change mode.
* @private
*/
ImageEditor.prototype.leaveModeInternal_ = function(commit, leaveToSwitchMode) {
if (!this.currentMode_)
return;
// If the current mode is 'Resize', and commit is required,
// leaving mode should be stopped when an input value is not valid.
if(commit && this.currentMode_.name === 'resize') {
var resizeMode = /** @type {!ImageEditor.Mode.Resize} */
(this.currentMode_);
if(!resizeMode.isInputValid()) {
resizeMode.showAlertDialog();
return;
}
}
this.modeToolbar_.show(false);
this.rootContainer_.removeAttribute('editor-mode');
// If it leaves to switch mode, do not restore screen size since the next mode
// might change screen size. We should avoid to show intermediate animation
// which tries to restore screen size.
if (!leaveToSwitchMode) {
this.getViewport().setScreenTop(ImageEditor.Toolbar.HEIGHT);
this.getViewport().setScreenBottom(ImageEditor.Toolbar.HEIGHT);
this.getImageView().applyViewportChange();
}
this.currentMode_.cleanUpUI();
if (commit) {
var self = this;
var command = this.currentMode_.getCommand();
if (command) { // Could be null if the user did not do anything.
this.commandQueue_.execute(command);
this.updateUndoRedo();
}
}
var filesToggleRipple =
this.currentTool_.querySelector('files-toggle-ripple');
if (filesToggleRipple)
filesToggleRipple.activated = false;
this.exitButton_.hidden = false;
this.currentMode_.cleanUpCaches();
this.currentMode_ = null;
this.currentTool_ = null;
};
/**
* Leave the mode, commit only if required by the current mode.
* @param {boolean} leaveToSwitchMode True if it leaves to switch mode.
*/
ImageEditor.prototype.leaveMode = function(leaveToSwitchMode) {
this.leaveModeInternal_(!!this.currentMode_ &&
this.currentMode_.updated_ &&
this.currentMode_.implicitCommit,
leaveToSwitchMode);
};
/**
* Enter the editor mode with the given name.
*
* @param {string} name Mode name.
* @private
*/
ImageEditor.prototype.enterModeByName_ = function(name) {
for (var i = 0; i !== this.modes_.length; i++) {
var mode = this.modes_[i];
if (mode.name === name) {
if (!mode.button_.hasAttribute('disabled'))
this.enterMode(mode);
return;
}
}
console.error('Mode "' + name + '" not found.');
};
/**
* Key down handler.
* @param {!Event} event The keydown event.
* @return {boolean} True if handled.
*/
ImageEditor.prototype.onKeyDown = function(event) {
if (this.currentMode_ && this.currentMode_.isConsumingKeyEvents())
return false;
switch (util.getKeyModifiers(event) + event.key) {
case 'Escape':
case 'Enter':
if (this.getMode()) {
this.leaveModeInternal_(event.key === 'Enter',
false /* not to switch mode */);
return true;
}
break;
case 'Ctrl-z': // Ctrl+Z
if (this.commandQueue_.canUndo()) {
this.undo();
return true;
}
break;
case 'Ctrl-y': // Ctrl+Y
if (this.commandQueue_.canRedo()) {
this.redo();
return true;
}
break;
case 'a':
this.enterModeByName_('autofix');
return true;
case 'b':
this.enterModeByName_('exposure');
return true;
case 'c':
this.enterModeByName_('crop');
return true;
case 'l':
this.enterModeByName_('rotate_left');
return true;
case 'r':
this.enterModeByName_('rotate_right');
return true;
}
return false;
};
/**
* Double tap handler.
* @param {number} x X coordinate of the event.
* @param {number} y Y coordinate of the event.
* @private
*/
ImageEditor.prototype.onDoubleTap_ = function(x, y) {
if (this.getMode()) {
var action = this.buffer_.getDoubleTapAction(x, y);
if (action === ImageBuffer.DoubleTapAction.COMMIT)
this.leaveModeInternal_(true, false /* not to switch mode */);
else if (action === ImageBuffer.DoubleTapAction.CANCEL)
this.leaveModeInternal_(false, false /* not to switch mode */);
}
};
/**
* Called when the user starts editing image.
*/
ImageEditor.prototype.onStartEditing = function() {
this.calculateModeApplicativity_();
};
/**
* A helper object for panning the ImageBuffer.
*
* @param {!HTMLElement} rootContainer The top-level container.
* @param {!HTMLElement} container The container for mouse events.
* @param {!ImageBuffer} buffer Image buffer.
* @constructor
* @struct
*/
ImageEditor.MouseControl = function(rootContainer, container, buffer) {
this.rootContainer_ = rootContainer;
this.container_ = container;
this.buffer_ = buffer;
var handlers = {
'touchstart': this.onTouchStart,
'touchend': this.onTouchEnd,
'touchcancel': this.onTouchCancel,
'touchmove': this.onTouchMove,
'mousedown': this.onMouseDown,
'mouseup': this.onMouseUp
};
for (var eventName in handlers) {
container.addEventListener(
eventName, handlers[eventName].bind(this), false);
}
// Mouse move handler has to be attached to the window to receive events
// from outside of the window. See: http://crbug.com/155705
window.addEventListener('mousemove', this.onMouseMove.bind(this), false);
/**
* @type {?ImageBuffer.DragHandler}
* @private
*/
this.dragHandler_ = null;
/**
* @type {boolean}
* @private
*/
this.dragHappened_ = false;
/**
* @type {?{x: number, y: number, time:number}}
* @private
*/
this.touchStartInfo_ = null;
/**
* @type {?{x: number, y: number, time:number}}
* @private
*/
this.previousTouchStartInfo_ = null;
};
/**
* Maximum movement for touch to be detected as a tap (in pixels).
* @private
* @const
*/
ImageEditor.MouseControl.MAX_MOVEMENT_FOR_TAP_ = 8;
/**
* Maximum time for touch to be detected as a tap (in milliseconds).
* @private
* @const
*/
ImageEditor.MouseControl.MAX_TAP_DURATION_ = 500;
/**
* Maximum distance from the first tap to the second tap to be considered
* as a double tap.
* @private
* @const
*/
ImageEditor.MouseControl.MAX_DISTANCE_FOR_DOUBLE_TAP_ = 32;
/**
* Maximum time for touch to be detected as a double tap (in milliseconds).
* @private
* @const
*/
ImageEditor.MouseControl.MAX_DOUBLE_TAP_DURATION_ = 1000;
/**
* Returns an event's position.
*
* @param {!(MouseEvent|Touch)} e Pointer position.
* @return {!Object} A pair of x,y in page coordinates.
* @private
*/
ImageEditor.MouseControl.getPosition_ = function(e) {
return {
x: e.pageX,
y: e.pageY
};
};
/**
* Returns touch position or null if there is more than one touch position.
*
* @param {!TouchEvent} e Event.
* @return {Object?} A pair of x,y in page coordinates.
* @private
*/
ImageEditor.MouseControl.prototype.getTouchPosition_ = function(e) {
if (e.targetTouches.length == 1)
return ImageEditor.MouseControl.getPosition_(e.targetTouches[0]);
else
return null;
};
/**
* Touch start handler.
* @param {!TouchEvent} e Event.
*/
ImageEditor.MouseControl.prototype.onTouchStart = function(e) {
var position = this.getTouchPosition_(e);
if (position) {
this.touchStartInfo_ = {
x: position.x,
y: position.y,
time: Date.now()
};
this.dragHandler_ = this.buffer_.getDragHandler(position.x, position.y,
true /* touch */);
this.dragHappened_ = false;
}
};
/**
* Touch end handler.
* @param {!TouchEvent} e Event.
*/
ImageEditor.MouseControl.prototype.onTouchEnd = function(e) {
if (!this.dragHappened_ &&
this.touchStartInfo_ &&
Date.now() - this.touchStartInfo_.time <=
ImageEditor.MouseControl.MAX_TAP_DURATION_) {
this.buffer_.onClick(this.touchStartInfo_.x, this.touchStartInfo_.y);
if (this.previousTouchStartInfo_ &&
Date.now() - this.previousTouchStartInfo_.time <
ImageEditor.MouseControl.MAX_DOUBLE_TAP_DURATION_) {
var prevTouchCircle = new Circle(
this.previousTouchStartInfo_.x,
this.previousTouchStartInfo_.y,
ImageEditor.MouseControl.MAX_DISTANCE_FOR_DOUBLE_TAP_);
if (prevTouchCircle.inside(this.touchStartInfo_.x,
this.touchStartInfo_.y)) {
this.doubleTapCallback_(this.touchStartInfo_.x, this.touchStartInfo_.y);
}
}
this.previousTouchStartInfo_ = this.touchStartInfo_;
} else {
this.previousTouchStartInfo_ = null;
}
this.onTouchCancel();
};
/**
* Default double tap handler.
* @param {number} x X coordinate of the event.
* @param {number} y Y coordinate of the event.
* @private
*/
ImageEditor.MouseControl.prototype.doubleTapCallback_ = function(x, y) {};
/**
* Sets callback to be called when double tap detected.
* @param {function(number, number)} callback New double tap callback.
*/
ImageEditor.MouseControl.prototype.setDoubleTapCallback = function(callback) {
this.doubleTapCallback_ = callback;
};
/**
* Touch cancel handler.
*/
ImageEditor.MouseControl.prototype.onTouchCancel = function() {
this.dragHandler_ = null;
this.dragHappened_ = false;
this.touchStartInfo_ = null;
this.lockMouse_(false);
};
/**
* Touch move handler.
* @param {!TouchEvent} e Event.
*/
ImageEditor.MouseControl.prototype.onTouchMove = function(e) {
var position = this.getTouchPosition_(e);
if (!position)
return;
if (this.touchStartInfo_ && !this.dragHappened_) {
var tapCircle = new Circle(
this.touchStartInfo_.x, this.touchStartInfo_.y,
ImageEditor.MouseControl.MAX_MOVEMENT_FOR_TAP_);
this.dragHappened_ = !tapCircle.inside(position.x, position.y);
}
if (this.dragHandler_ && this.dragHappened_) {
this.dragHandler_(position.x, position.y, e.shiftKey);
this.lockMouse_(true);
}
};
/**
* Mouse down handler.
* @param {!MouseEvent} e Event.
*/
ImageEditor.MouseControl.prototype.onMouseDown = function(e) {
var position = ImageEditor.MouseControl.getPosition_(e);
this.dragHandler_ = this.buffer_.getDragHandler(position.x, position.y,
false /* mouse */);
this.dragHappened_ = false;
this.updateCursor_(position);
};
/**
* Mouse up handler.
* @param {!MouseEvent} e Event.
*/
ImageEditor.MouseControl.prototype.onMouseUp = function(e) {
var position = ImageEditor.MouseControl.getPosition_(e);
if (!this.dragHappened_) {
this.buffer_.onClick(position.x, position.y);
}
this.dragHandler_ = null;
this.dragHappened_ = false;
this.lockMouse_(false);
};
/**
* Mouse move handler.
* @param {!Event} e Event.
*/
ImageEditor.MouseControl.prototype.onMouseMove = function(e) {
e = assertInstanceof(e, MouseEvent);
var position = ImageEditor.MouseControl.getPosition_(e);
if (this.dragHandler_ && !e.which) {
// mouseup must have happened while the mouse was outside our window.
this.dragHandler_ = null;
this.lockMouse_(false);
}
this.updateCursor_(position);
if (this.dragHandler_) {
this.dragHandler_(position.x, position.y, e.shiftKey);
this.dragHappened_ = true;
this.lockMouse_(true);
}
};
/**
* Update the UI to reflect mouse drag state.
* @param {boolean} on True if dragging.
* @private
*/
ImageEditor.MouseControl.prototype.lockMouse_ = function(on) {
ImageUtil.setAttribute(this.rootContainer_, 'mousedrag', on);
};
/**
* Update the cursor.
*
* @param {!Object} position An object holding x and y properties.
* @private
*/
ImageEditor.MouseControl.prototype.updateCursor_ = function(position) {
var oldCursor = this.container_.getAttribute('cursor');
var newCursor = this.buffer_.getCursorStyle(
position.x, position.y, !!this.dragHandler_);
if (newCursor != oldCursor) // Avoid flicker.
this.container_.setAttribute('cursor', newCursor);
};
/**
* A toolbar for the ImageEditor.
* @param {!HTMLElement} parent The parent element.
* @param {function(string)} displayStringFunction A string formatting function.
* @param {function(Object)=} opt_updateCallback The callback called when
* controls change.
* @param {boolean=} opt_showActionButtons True to show action buttons.
* @constructor
* @extends {cr.EventTarget}
* @struct
*/
ImageEditor.Toolbar = function(
parent, displayStringFunction, opt_updateCallback, opt_showActionButtons) {
this.wrapper_ = parent;
this.displayStringFunction_ = displayStringFunction;
/**
* @type {?function(Object)}
* @private
*/
this.updateCallback_ = opt_updateCallback || null;
// Create action buttons.
if (opt_showActionButtons) {
var actionButtonsLayer = document.createElement('div');
actionButtonsLayer.classList.add('action-buttons');
this.cancelButton_ = ImageEditor.Toolbar.createButton_(
'GALLERY_CANCEL_LABEL', ImageEditor.Toolbar.ButtonType.LABEL_UPPER_CASE,
this.onCancelClicked_.bind(this), 'cancel');
actionButtonsLayer.appendChild(this.cancelButton_);
this.doneButton_ = ImageEditor.Toolbar.createButton_(
'GALLERY_DONE', ImageEditor.Toolbar.ButtonType.LABEL_UPPER_CASE,
this.onDoneClicked_.bind(this), 'done');
actionButtonsLayer.appendChild(this.doneButton_);
this.wrapper_.appendChild(actionButtonsLayer);
}
/**
* @private {!HTMLElement}
*/
this.container_ = /** @type {!HTMLElement} */ (document.createElement('div'));
this.container_.classList.add('container');
this.wrapper_.appendChild(this.container_);
};
ImageEditor.Toolbar.prototype.__proto__ = cr.EventTarget.prototype;
/**
* Height of the toolbar.
* @const {number}
*/
ImageEditor.Toolbar.HEIGHT = 48; // px
/**
* Handles click event of done button.
* @private
*/
ImageEditor.Toolbar.prototype.onDoneClicked_ = function() {
this.doneButton_.querySelector('paper-ripple').simulatedRipple();
var event = new Event('done-clicked');
this.dispatchEvent(event);
};
/**
* Handles click event of cancel button.
* @private
*/
ImageEditor.Toolbar.prototype.onCancelClicked_ = function() {
this.cancelButton_.querySelector('paper-ripple').simulatedRipple();
var event = new Event('cancel-clicked');
this.dispatchEvent(event);
};
/**
* Returns the parent element.
* @return {!HTMLElement}
*/
ImageEditor.Toolbar.prototype.getElement = function() {
return this.container_;
};
/**
* Clear the toolbar.
*/
ImageEditor.Toolbar.prototype.clear = function() {
ImageUtil.removeChildren(this.container_);
};
/**
* Add a control.
* @param {!HTMLElement} element The control to add.
* @return {!HTMLElement} The added element.
*/
ImageEditor.Toolbar.prototype.add = function(element) {
this.container_.appendChild(element);
return element;
};
/**
* Button type.
* @enum {string}
*/
ImageEditor.Toolbar.ButtonType = {
ICON: 'icon',
ICON_TOGGLEABLE: 'icon_toggleable',
LABEL: 'label',
LABEL_UPPER_CASE: 'label_upper_case'
};
/**
* Create a button.
*
* @param {string} title String ID of button title.
* @param {ImageEditor.Toolbar.ButtonType} type Button type.
* @param {function(Event)} handler onClick handler.
* @param {string=} opt_class Extra class name.
* @return {!HTMLElement} The created button.
* @private
*/
ImageEditor.Toolbar.createButton_ = function(
title, type, handler, opt_class) {
var button = /** @type {!HTMLElement} */ (document.createElement('button'));
if (opt_class)
button.classList.add(opt_class);
button.classList.add('edit-toolbar');
if (type === ImageEditor.Toolbar.ButtonType.ICON ||
type === ImageEditor.Toolbar.ButtonType.ICON_TOGGLEABLE) {
var icon = document.createElement('div');
icon.classList.add('icon');
// Show tooltip for icon button.
assertInstanceof(document.querySelector('files-tooltip'), FilesTooltip)
.addTarget(button);
button.appendChild(icon);
if (type === ImageEditor.Toolbar.ButtonType.ICON) {
var filesRipple = document.createElement('files-ripple');
button.appendChild(filesRipple);
} else {
var filesToggleRipple = document.createElement('files-toggle-ripple');
button.appendChild(filesToggleRipple);
}
} else if (type === ImageEditor.Toolbar.ButtonType.LABEL ||
type === ImageEditor.Toolbar.ButtonType.LABEL_UPPER_CASE) {
var label = document.createElement('span');
label.classList.add('label');
label.textContent =
type === ImageEditor.Toolbar.ButtonType.LABEL_UPPER_CASE ?
strf(title).toLocaleUpperCase() : strf(title);
button.appendChild(label);
var paperRipple = document.createElement('paper-ripple');
button.appendChild(paperRipple);
} else {
assertNotReached();
}
button.label = strf(title);
button.setAttribute('aria-label', strf(title));
GalleryUtil.decorateMouseFocusHandling(button);
button.addEventListener('click', handler, false);
button.addEventListener('keydown', function(event) {
// Stop propagation of Enter key event to prevent it from being captured by
// image editor.
if (event.key === 'Enter')
event.stopPropagation();
});
return button;
};
/**
* Add a button.
*
* @param {string} title Button title.
* @param {ImageEditor.Toolbar.ButtonType} type Button type.
* @param {function(Event)} handler onClick handler.
* @param {string=} opt_class Extra class name.
* @return {!HTMLElement} The added button.
*/
ImageEditor.Toolbar.prototype.addButton = function(
title, type, handler, opt_class) {
var button = ImageEditor.Toolbar.createButton_(
title, type, handler, opt_class);
this.add(button);
return button;
};
/**
* Add a input field.
*
* @param {string} name Input name
* @param {string} title Input title
* @param {function(Event)} handler onInput and onChange handler
* @param {string|number} value Default value
* @param {string=} opt_unit Unit for an input field
* @return {!HTMLElement} Input Element
*/
ImageEditor.Toolbar.prototype.addInput = function(
name, title, handler, value, opt_unit) {
var input = /** @type {!HTMLElement} */ (document.createElement('div'));
input.classList.add('input', name);
var text = document.createElement('paper-input');
text.setAttribute('label', strf(title));
text.classList.add('text', name);
text.value = value;
// We should listen to not only 'change' event, but also 'input' because we
// want to update values as soon as the user types characters.
text.addEventListener('input', handler, false);
text.addEventListener('change', handler, false);
input.appendChild(text);
if(opt_unit) {
var unit_label = document.createElement('span');
unit_label.textContent = opt_unit;
unit_label.classList.add('unit_label');
input.appendChild(unit_label);
}
input.name = name;
input.getValue = function(text) {
return text.value;
}.bind(this, text);
input.setValue = function(text, value) {
text.value = value;
}.bind(this, text);
this.add(input);
return input;
};
/**
* Add a range control (scalar value picker).
*
* @param {string} name An option name.
* @param {string} title An option title.
* @param {number} min Min value of the option.
* @param {number} value Default value of the option.
* @param {number} max Max value of the options.
* @param {number=} opt_scale A number to multiply by when setting
* min/value/max in DOM.
* @param {boolean=} opt_showNumeric True if numeric value should be displayed.
* @return {!HTMLElement} Range element.
*/
ImageEditor.Toolbar.prototype.addRange = function(
name, title, min, value, max, opt_scale, opt_showNumeric) {
var range = /** @type {!HTMLElement} */ (document.createElement('div'));
range.classList.add('range', name);
var icon = document.createElement('icon');
icon.classList.add('icon');
range.appendChild(icon);
var label = document.createElement('span');
label.textContent = strf(title);
label.classList.add('label');
range.appendChild(label);
var scale = opt_scale || 1;
var slider = document.createElement('paper-slider');
slider.min = Math.ceil(min * scale);
slider.max = Math.floor(max * scale);
slider.value = value * scale;
slider.addEventListener('change', function(event) {
if (this.updateCallback_)
this.updateCallback_(this.getOptions());
}.bind(this));
range.appendChild(slider);
range.name = name;
range.getValue = function(slider, scale) {
return slider.value / scale;
}.bind(this, slider, scale);
// Swallow the left and right keys, so they are not handled by other
// listeners.
range.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight')
e.stopPropagation();
});
this.add(range);
return range;
};
/**
* @return {!Object} options A map of options.
*/
ImageEditor.Toolbar.prototype.getOptions = function() {
var values = {};
for (var child = this.container_.firstChild;
child;
child = child.nextSibling) {
if (child.name)
values[child.name] = child.getValue();
}
return values;
};
/**
* Reset the toolbar.
*/
ImageEditor.Toolbar.prototype.reset = function() {
for (var child = this.wrapper_.firstChild; child; child = child.nextSibling) {
if (child.reset) child.reset();
}
};
/**
* Show/hide the toolbar.
* @param {boolean} on True if show.
*/
ImageEditor.Toolbar.prototype.show = function(on) {
if (!this.wrapper_.firstChild)
return; // Do not show empty toolbar;
this.wrapper_.hidden = !on;
};
/** A prompt panel for the editor.
*
* @param {!HTMLElement} container Container element.
* @param {function(string, ...string)} displayStringFunction A formatting
* function.
* @constructor
* @struct
*/
ImageEditor.Prompt = function(container, displayStringFunction) {
this.container_ = container;
this.displayStringFunction_ = displayStringFunction;
/**
* @type {HTMLDivElement}
* @private
*/
this.wrapper_ = null;
/**
* @type {HTMLDivElement}
* @private
*/
this.prompt_ = null;
/**
* @type {number}
* @private
*/
this.timer_ = 0;
};
/**
* Reset the prompt.
*/
ImageEditor.Prompt.prototype.reset = function() {
this.cancelTimer();
if (this.wrapper_) {
this.container_.removeChild(this.wrapper_);
this.wrapper_ = null;
this.prompt_ = null;
}
};
/**
* Cancel the delayed action.
*/
ImageEditor.Prompt.prototype.cancelTimer = function() {
if (this.timer_) {
clearTimeout(this.timer_);
this.timer_ = 0;
}
};
/**
* Schedule the delayed action.
* @param {function()} callback Callback.
* @param {number} timeout Timeout.
*/
ImageEditor.Prompt.prototype.setTimer = function(callback, timeout) {
this.cancelTimer();
var self = this;
this.timer_ = setTimeout(function() {
self.timer_ = 0;
callback();
}, timeout);
};
/**
* Show the prompt.
*
* @param {string} text The prompt text.
* @param {number=} opt_timeout Timeout in ms.
* @param {...Object} var_args varArgs for the formatting function.
*/
ImageEditor.Prompt.prototype.show = function(text, opt_timeout, var_args) {
var args = [text].concat(Array.prototype.slice.call(arguments, 2));
var message = this.displayStringFunction_.apply(null, args);
this.showStringAt('center', message, opt_timeout);
};
/**
* Show the position at the specific position.
*
* @param {string} pos The 'pos' attribute value.
* @param {string} text The prompt text.
* @param {number} timeout Timeout in ms.
* @param {...Object} var_args varArgs for the formatting function.
*/
ImageEditor.Prompt.prototype.showAt = function(
pos, text, timeout, var_args) {
var args = [text].concat(Array.prototype.slice.call(arguments, 3));
var message = this.displayStringFunction_.apply(null, args);
this.showStringAt(pos, message, timeout);
};
/**
* Show the string in the prompt
*
* @param {string} pos The 'pos' attribute value.
* @param {string} text The prompt text.
* @param {number=} opt_timeout Timeout in ms.
*/
ImageEditor.Prompt.prototype.showStringAt = function(pos, text, opt_timeout) {
this.reset();
if (!text)
return;
var document = this.container_.ownerDocument;
this.wrapper_ = assertInstanceof(document.createElement('div'),
HTMLDivElement);
this.wrapper_.className = 'prompt-wrapper';
this.wrapper_.setAttribute('pos', pos);
this.container_.appendChild(this.wrapper_);
this.prompt_ = assertInstanceof(document.createElement('div'),
HTMLDivElement);
this.prompt_.className = 'prompt';
// Create an extra wrapper which opacity can be manipulated separately.
var tool = document.createElement('div');
tool.className = 'dimmable';
this.wrapper_.appendChild(tool);
tool.appendChild(this.prompt_);
this.prompt_.textContent = text;
var close = document.createElement('div');
close.className = 'close';
close.addEventListener('click', this.hide.bind(this));
this.prompt_.appendChild(close);
setTimeout(
this.prompt_.setAttribute.bind(this.prompt_, 'state', 'fadein'), 0);
if (opt_timeout)
this.setTimer(this.hide.bind(this), opt_timeout);
};
/**
* Hide the prompt.
*/
ImageEditor.Prompt.prototype.hide = function() {
if (!this.prompt_) return;
this.prompt_.setAttribute('state', 'fadeout');
// Allow some time for the animation to play out.
this.setTimer(this.reset.bind(this), 500);
};