adobe/brackets

View on GitHub
src/extensions/default/InlineColorEditor/ColorEditor.js

Summary

Maintainability
F
4 days
Test Coverage
/*
 * Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*jslint regexp: true */

define(function (require, exports, module) {
    "use strict";

    var KeyEvent           = brackets.getModule("utils/KeyEvent"),
        PreferencesManager = brackets.getModule("preferences/PreferencesManager"),
        StringUtils        = brackets.getModule("utils/StringUtils"),
        Strings            = brackets.getModule("strings"),
        Mustache           = brackets.getModule("thirdparty/mustache/mustache"),
        tinycolor          = require("thirdparty/tinycolor-min");

    /** Mustache template that forms the bare DOM structure of the UI */
    var ColorEditorTemplate = require("text!ColorEditorTemplate.html");

    /**
     * @const @type {number}
     */
    var STEP_MULTIPLIER = 5;

    /**
     * Convert 0x notation into hex6 format for tinycolor
     * compatibility: ("0xFFAACC" => "#FFFFFF")
     * @param {string} str - String to ensure hex format for
     * @returns {string} - str in hex format
     */
    function ensureHexFormat(str) {
        return (/^0x/).test(str) ? str.replace("0x","#") : str;
    }

    /**
     * Converts a color to a 0x-prefixed string
     * @param {tinycolor} color - color to convert
     * @returns {string} - color as 0x-prefixed string
     */
    function as0xString(color) {
        return color.toHexString().replace("#", "0x");
    }

    /**
     * Converts 0x-prefixed color to hex
     * @param {string} color - Color to convert
     * @param {boolean} convertToString - true if color should 
     *                                    be returned as string
     * @returns {tinycolor|string} - Hex color as a Tinycolor object
     *                               or a hex string
     */
    function _0xColorToHex(color, convertToStr) {
        var hexColor = tinycolor(color.replace("0x", "#"));
        hexColor._format = "0x";

        if (convertToStr) {
            return hexColor.toString();
        }
        return hexColor;
    }

    /**
     * Ensures that a string is in Tinycolor supported format
     * @param {string} color - Color to check the format for
     * @param {boolean} convertToString - true if color should 
     *                                    be returned as string
     * @returns {tinycolor|string} - Color as a Tinycolor object
     *                               or a hex string
     */
    function checkSetFormat(color, convertToStr) {
        if ((/^0x/).test(color)) {
            return _0xColorToHex(color, convertToStr);
        }
        if (convertToStr) {
            return tinycolor(color).toString();
        }
        return tinycolor(color);
    }

    /**
     * Color picker control; may be used standalone or within an InlineColorEditor inline widget.
     * @param {!jQuery} $parent  DOM node into which to append the root of the color picker UI
     * @param {!string} color  Initially selected color
     * @param {!function(string)} callback  Called whenever selected color changes
     * @param {!Array.<{value:string, count:number}>} swatches  Quick-access color swatches to include in UI
     */
    function ColorEditor($parent, color, callback, swatches) {
        // Create the DOM structure, filling in localized strings via Mustache
        this.$element = $(Mustache.render(ColorEditorTemplate, Strings));
        $parent.append(this.$element);

        this._callback = callback;

        this._handleKeydown = this._handleKeydown.bind(this);
        this._handleOpacityKeydown = this._handleOpacityKeydown.bind(this);
        this._handleHslKeydown = this._handleHslKeydown.bind(this);
        this._handleHueKeydown = this._handleHueKeydown.bind(this);
        this._handleSelectionKeydown = this._handleSelectionKeydown.bind(this);
        this._handleOpacityDrag = this._handleOpacityDrag.bind(this);
        this._handleHueDrag = this._handleHueDrag.bind(this);
        this._handleSelectionFieldDrag = this._handleSelectionFieldDrag.bind(this);

        this._originalColor = color;
        this._color = checkSetFormat(color);

        this._redoColor = null;
        this._isUpperCase = PreferencesManager.get("uppercaseColors");
        PreferencesManager.on("change", "uppercaseColors", function () {
            this._isUpperCase = PreferencesManager.get("uppercaseColors");
        }.bind(this));

        this.$colorValue = this.$element.find(".color-value");
        this.$buttonList = this.$element.find("ul.button-bar");
        this.$rgbaButton = this.$element.find(".rgba");
        this.$hexButton = this.$element.find(".hex");
        this.$hslButton = this.$element.find(".hsla");
        this.$0xButton = this.$element.find(".0x");
        this.$currentColor = this.$element.find(".current-color");
        this.$originalColor = this.$element.find(".original-color");
        this.$selection = this.$element.find(".color-selection-field");
        this.$selectionBase = this.$element.find(".color-selection-field .selector-base");
        this.$hueBase = this.$element.find(".hue-slider .selector-base");
        this.$opacityGradient = this.$element.find(".opacity-gradient");
        this.$hueSlider = this.$element.find(".hue-slider");
        this.$hueSelector = this.$element.find(".hue-slider .selector-base");
        this.$opacitySlider = this.$element.find(".opacity-slider");
        this.$opacitySelector = this.$element.find(".opacity-slider .selector-base");
        this.$swatches = this.$element.find(".swatches");

        // Create quick-access color swatches
        this._addSwatches(swatches);

        // Attach event listeners to main UI elements
        this._addListeners();

        // Initially selected color
        this.$originalColor.css("background-color", checkSetFormat(this._originalColor));
        
        this._commitColor(color);   
    }

    /**
     * A string or tinycolor object representing the currently selected color
     * TODO (#2201): type is unpredictable
     * @type {tinycolor|string}
     */
    ColorEditor.prototype._color = null;

    /**
     * An HSV representation of the currently selected color.
     * TODO (#2201): type of _hsv.s/.v is unpredictable
     * @type {!{h:number, s:number|string, v:number|string, a:number}}
     */
    ColorEditor.prototype._hsv = tinycolor("rgba(0,0,0,1)").toHsv();

    /**
     * Color that was selected before undo(), if undo was the last change made. Else null.
     * @type {?string}
     */
    ColorEditor.prototype._redoColor = null;

    /**
     * Initial value the color picker was opened with
     * @type {!string}
     */
    ColorEditor.prototype._originalColor = null;


    /** Returns the root DOM node of the ColorPicker UI */
    ColorEditor.prototype.getRootElement = function () {
        return this.$element;
    };

    /** Attach event listeners for main UI elements */
    ColorEditor.prototype._addListeners = function () {
        this._bindColorFormatToRadioButton("rgba");
        this._bindColorFormatToRadioButton("hex");
        this._bindColorFormatToRadioButton("hsla");
        this._bindColorFormatToRadioButton("0x");

        this._bindInputHandlers();

        this._bindOriginalColorButton();

        this._registerDragHandler(this.$selection, this._handleSelectionFieldDrag);
        this._registerDragHandler(this.$hueSlider, this._handleHueDrag);
        this._registerDragHandler(this.$opacitySlider, this._handleOpacityDrag);
        this._bindKeyHandler(this.$selectionBase, this._handleSelectionKeydown);
        this._bindKeyHandler(this.$hueBase, this._handleHueKeydown);
        this._bindKeyHandler(this.$opacitySelector, this._handleOpacityKeydown);
        this._bindKeyHandler(this.$hslButton, this._handleHslKeydown);

        // General key handler gets bubbling events from any focusable part of widget
        this._bindKeyHandler(this.$element, this._handleKeydown);
    };

    /**
     * Update all UI elements to reflect the selected color (_color and _hsv). It is usually
     * incorrect to call this directly; use _commitColor() or setColorAsHsv() instead.
     */

    ColorEditor.prototype._synchronize = function () {
        var colorValue  = this.getColor().getOriginalInput();
        var colorObject = checkSetFormat(colorValue);
        var hueColor    = "hsl(" + this._hsv.h + ", 100%, 50%)";
        this._updateColorTypeRadioButtons(colorObject.getFormat());
        this.$colorValue.val(colorValue);
        this.$currentColor.css("background-color", checkSetFormat(colorValue, true));
        this.$selection.css("background-color", hueColor);
        this.$hueBase.css("background-color", hueColor);

        // Update gradients in color square & opacity slider
        this.$selectionBase.css("background-color", colorObject.toHexString());
        this.$opacityGradient.css("background-image", "linear-gradient(" + hueColor + ", transparent)");

        // Update slider thumb positions
        this.$hueSelector.css("bottom", (this._hsv.h / 360 * 100) + "%");
        this.$opacitySelector.css("bottom", (this._hsv.a * 100) + "%");
        if (!isNaN(this._hsv.s)) {      // TODO (#2201): type of _hsv.s/.v is unpredictable
            this._hsv.s = (this._hsv.s * 100) + "%";
        }
        if (!isNaN(this._hsv.v)) {
            this._hsv.v = (this._hsv.v * 100) + "%";
        }
        this.$selectionBase.css({
            left: this._hsv.s,
            bottom: this._hsv.v
        });
    };

    /**
     * Focus the main color square's thumb.
     * @return {boolean} True if we focused the square, false otherwise.
     */
    ColorEditor.prototype.focus = function () {
        if (!this.$selectionBase.is(":focus")) {
            this.$selectionBase.focus();
            return true;
        }
        return false;
    };

    /**
     * Remove any preference listeners before destroying the editor.
     */
    ColorEditor.prototype.destroy = function () {
        PreferencesManager.off("change", "uppercaseColors");
    };

    /**
     * @return {tinycolor|string} The currently selected color (TODO (#2201): type is unpredictable).
     */
    ColorEditor.prototype.getColor = function () {
        return this._color;
    };

    /** Update the format button bar's selection */
    ColorEditor.prototype._updateColorTypeRadioButtons = function (format) {
        this.$buttonList.find("li").removeClass("selected");
        switch (format) {
        case "rgb":
            this.$buttonList.find(".rgba").parent().addClass("selected");
            break;
        case "hex":
        case "name":
            this.$buttonList.find(".hex").parent().addClass("selected");
            break;
        case "hsl":
            this.$buttonList.find(".hsla").parent().addClass("selected");
            break;
        case "0x":
            this.$buttonList.find(".0x").parent().addClass("selected");
            break;
        }
    };

    /** Add event listeners to the format button bar */
    ColorEditor.prototype._bindColorFormatToRadioButton = function (buttonClass, propertyName, value) {
        var handler,
            self = this;
        handler = function (event) {
            var newFormat   = $(event.currentTarget).html().toLowerCase().replace("%", "p"),
                newColor    = self.getColor().toString();

            var colorObject = checkSetFormat(newColor);

            switch (newFormat) {
            case "hsla":
                newColor = colorObject.toHslString();
                break;
            case "rgba":
                newColor = colorObject.toRgbString();
                break;
            case "prgba":
                newColor = colorObject.toPercentageRgbString();
                break;
            case "hex":
                newColor = colorObject.toHexString();
                self._hsv.a = 1;
                break;
            case "0x":
                newColor = as0xString(colorObject);
                self._hsv.a = 1;
                self._format = "0x";
                break;
            }

            // We need to run this again whenever RGB/HSL/Hex conversions
            // are performed to preserve the case
            newColor = self._isUpperCase ? newColor.toUpperCase() : newColor;
            self._commitColor(newColor, false);
        };
        this.$element.find("." + buttonClass).click(handler);
    };

    /** Add event listener to the "original color value" swatch */
    ColorEditor.prototype._bindOriginalColorButton = function () {
        var self = this;
        this.$originalColor.click(function (event) {
            self._commitColor(self._originalColor, true);
        });
    };

    /**
     * Convert percentage values in an RGB color into normal RGB values in the range of 0 - 255.
     * If the original color is already in non-percentage format, does nothing.
     * @param {string} color The color to be converted to non-percentage RGB color string.
     * @return {string} an RGB color string in the normal format using non-percentage values
     */
    ColorEditor.prototype._convertToNormalRGB = function (color) {
        var matches = color.match(/^rgb.*?([0-9]+)\%.*?([0-9]+)\%.*?([0-9]+)\%/i);
        if (matches) {
            var i, percentStr, value;
            for (i = 0; i < 3; i++) {
                percentStr = matches[i + 1];
                value = Math.round(255 * Number(percentStr) / 100);
                if (!isNaN(value)) {
                    color = color.replace(percentStr + "%", value);
                }
            }
        }
        return color;
    };

    /**
     * Normalize the given color string into the format used by tinycolor, by adding a space
     * after commas.
     * @param {string} color The color to be corrected if it looks like an RGB or HSL color.
     * @return {string} a normalized color string.
     */
    ColorEditor.prototype._normalizeColorString = function (color) {
        var normalizedColor = color;

        // Convert 6-digit hex to 3-digit hex as TinyColor (#ffaacc -> #fac)
        if (color.match(/^#[0-9a-fA-F]{6}/)) {
            return tinycolor(color).toString();
        }
        if (color.match(/^(rgb|hsl)/i)) {
            normalizedColor = normalizedColor.replace(/,\s*/g, ", ");
            normalizedColor = normalizedColor.replace(/\(\s+/, "(");
            normalizedColor = normalizedColor.replace(/\s+\)/, ")");
        }
        return normalizedColor;
    };

    /** Handle changes in text field */
    ColorEditor.prototype._handleTextFieldInput = function (losingFocus) {
        var newColor    = $.trim(this.$colorValue.val()),
            newColorObj = checkSetFormat(newColor),
            newColorOk  = newColorObj.isValid();


        // TinyColor will auto correct an incomplete rgb or hsl value into a valid color value.
        // eg. rgb(0,0,0 -> rgb(0, 0, 0)
        // We want to avoid having TinyColor do this, because we don't want to sync the color
        // to the UI if it's incomplete. To accomplish this, we first normalize the original
        // color string into the format TinyColor would generate, and then compare it to what
        // TinyColor actually generates to see if it's different. If so, then we assume the color
        // was incomplete to begin with.
        if (newColorOk) {
            newColorOk = (newColorObj.toString() === this._normalizeColorString(ensureHexFormat(newColor)));
        }

        // Restore to the previous valid color if the new color is invalid or incomplete.
        if (losingFocus && !newColorOk) {
            newColor = this.getColor().toString();
        }

        // Sync only if we have a valid color or we're restoring the previous valid color.
        if (losingFocus || newColorOk) {
            this._commitColor(newColor, true);
        }
    };

    ColorEditor.prototype._bindInputHandlers = function () {
        var self = this;

        this.$colorValue.bind("input", function (event) {
            self._handleTextFieldInput(false);
        });

        this.$colorValue.bind("change", function (event) {
            self._handleTextFieldInput(true);
        });
    };

    /**
     * Populate the UI with the given color swatches and add listeners so they're selectable.
     * @param {!Array.<{value:string, count:number}>} swatches
     */
    ColorEditor.prototype._addSwatches = function (swatches) {
        var self = this;

        // Create swatches
        swatches.forEach(function (swatch) {
            var swatchValue = checkSetFormat(swatch.value, true);
            var stringFormat = (swatch.count > 1) ? Strings.COLOR_EDITOR_USED_COLOR_TIP_PLURAL : Strings.COLOR_EDITOR_USED_COLOR_TIP_SINGULAR,
                usedColorTip = StringUtils.format(stringFormat, swatch.value, swatch.count);

            self.$swatches.append("<li tabindex='0'><div class='swatch-bg'><div class='swatch' style='background-color: " +
                    swatchValue + ";' title='" + usedColorTip + "'></div></div> <span class='value'" + " title='" +
                    usedColorTip + "'>" + swatch.value + "</span></li>");
        });

        // Add key & click listeners to each
        this.$swatches.find("li").keydown(function (event) {
            if (event.keyCode === KeyEvent.DOM_VK_RETURN ||
                    event.keyCode === KeyEvent.DOM_VK_ENTER ||
                    event.keyCode === KeyEvent.DOM_VK_SPACE) {
                // Enter/Space is same as clicking on swatch

                self._commitColor($(event.currentTarget).find(".value").html());
            } else if (event.keyCode === KeyEvent.DOM_VK_TAB) {
                // Tab on last swatch loops back to color square
                if (!event.shiftKey && $(this).next("li").length === 0) {
                    self.$selectionBase.focus();
                    return false;
                }
            }
        });

        this.$swatches.find("li").click(function (event) {
            self._commitColor($(event.currentTarget).find(".value").html());
        });
    };

    /**
     * Checks whether colorVal is a valid color
     * @param {!string} colorVal
     * @return {boolean} Whether colorVal is valid
     */
    ColorEditor.prototype.isValidColor = function (colorVal) {
        return tinycolor(colorVal).isValid();
    };

    /**
     * Sets _hsv and _color based on an HSV input, and updates the UI. Attempts to preserve
     * the previous color format.
     * @param {!{h:number=, s:number=, v:number=}} hsv  Any missing values use the previous color's values.
     */
    ColorEditor.prototype.setColorAsHsv = function (hsv) {
        var colorVal, newColor,
            oldFormat = tinycolor(this.getColor()).getFormat();

        // Set our state to the new color
        $.extend(this._hsv, hsv);
        newColor = tinycolor(this._hsv);

        switch (oldFormat) {
        case "hsl":
            colorVal = newColor.toHslString();
            break;
        case "rgb":
            colorVal = newColor.toRgbString();
            break;
        case "prgb":
            colorVal = newColor.toPercentageRgbString();
            break;
        case "hex":
        case "name":
            colorVal = this._hsv.a < 1 ? newColor.toRgbString() : newColor.toHexString();
            break;
        case "0x":
            colorVal = as0xString(newColor);
            break;
        }
        colorVal = this._isUpperCase ? colorVal.toUpperCase() : colorVal;
        this._commitColor(colorVal, false);
    };

    /**
     * Sets _color (and optionally _hsv) based on a string input, and updates the UI. The string's
     * format determines the new selected color's format.
     * @param {!string} colorVal
     * @param {boolean=} resetHsv  Pass false ONLY if hsv set already been modified to match colorVal. Default: true.
     */
    ColorEditor.prototype._commitColor = function (colorVal, resetHsv) {

        if (resetHsv === undefined) {
            resetHsv = true;
        }
        this._callback(colorVal);

        var colorObj = checkSetFormat(colorVal);
        colorObj._originalInput = colorVal;
        this._color = colorObj;

        if (resetHsv) {
            this._hsv = this._color.toHsv();
        }

        this._redoColor = null;  // if we had undone, this new value blows away the redo history
        this._synchronize();
    };

    /**
     * Sets _color and _hsv based on a string input, and updates the UI. The string's
     * format determines the new selected color's format.
     * @param {!string} colorVal
     */
    ColorEditor.prototype.setColorFromString = function (colorVal) {
        this._commitColor(colorVal, true);  // TODO (#2204): make this less entangled with setColorAsHsv()
    };

    /** Converts a mouse coordinate to be relative to zeroPos, and clips to [0, maxOffset] */
    function _getNewOffset(pos, zeroPos, maxOffset) {
        var offset = pos - zeroPos;
        offset = Math.min(maxOffset, Math.max(0, offset));
        return offset;
    }

    /** Dragging color square's thumb */
    ColorEditor.prototype._handleSelectionFieldDrag = function (event) {
        var height  = this.$selection.height(),
            width   = this.$selection.width(),
            xOffset = _getNewOffset(event.clientX, this.$selection.offset().left, width),
            yOffset = _getNewOffset(event.clientY, this.$selection.offset().top, height),
            hsv     = {};
        hsv.s = xOffset / width;
        hsv.v = 1 - yOffset / height;
        this.setColorAsHsv(hsv, false);
        if (!this.$selection.find(".selector-base").is(":focus")) {
            this.$selection.find(".selector-base").focus();
        }
    };

    /** Dragging hue slider thumb */
    ColorEditor.prototype._handleHueDrag = function (event) {
        var height = this.$hueSlider.height(),
            offset = _getNewOffset(event.clientY, this.$hueSlider.offset().top, height),
            hsv    = {};
        hsv.h = (1 - offset / height) * 360;
        this.setColorAsHsv(hsv, false);
        if (!this.$hueSlider.find(".selector-base").is(":focus")) {
            this.$hueSlider.find(".selector-base").focus();
        }
    };

    /** Dragging opacity slider thumb */
    ColorEditor.prototype._handleOpacityDrag = function (event) {
        var height = this.$opacitySlider.height(),
            offset = _getNewOffset(event.clientY, this.$opacitySlider.offset().top, height),
            hsv    = {};
        hsv.a = 1 - (offset / height);
        this.setColorAsHsv(hsv, false);
        if (!this.$opacitySlider.find(".selector-base").is(":focus")) {
            this.$opacitySlider.find(".selector-base").focus();
        }
    };

    /**
     * Helper for attaching drag-related mouse listeners to an element. It's up to
     * 'handler' to actually move the element as mouse is dragged.
     * @param {!function(jQuery.event)} handler  Called whenever drag position changes
     */
    ColorEditor.prototype._registerDragHandler = function ($element, handler) {
        var mouseupHandler = function (event) {
            $(window).unbind("mousemove", handler);
            $(window).unbind("mouseup", mouseupHandler);
        };
        $element.mousedown(function (event) {
            $(window).bind("mousemove", handler);
            $(window).bind("mouseup", mouseupHandler);
        });
        $element.mousedown(handler);  // run drag-update handler on initial mousedown too
    };

    /**
     * Handles undo gestures while color picker has focus. We don't want to let CodeMirror's
     * usual undo logic run since it will destroy our marker.
     */
    ColorEditor.prototype.undo = function () {
        if (this._originalColor.toString() !== this._color.toString()) {
            this._commitColor(this._originalColor, true);
            this._redoColor = this._color.toString();
        }
    };

    /** Similarly, handle redo gestures while color picker has focus. */
    ColorEditor.prototype.redo = function () {
        if (this._redoColor) {
            this._commitColor(this._redoColor, true);
            this._redoColor = null;
        }
    };

    /**
     * Global handler for keys in the color editor. Catches undo/redo keys and traps
     * arrow keys that would be handled by the scroller.
     */
    ColorEditor.prototype._handleKeydown = function (event) {
        var hasCtrl = (brackets.platform === "win") ? (event.ctrlKey) : (event.metaKey);
        if (hasCtrl) {
            switch (event.keyCode) {
            case KeyEvent.DOM_VK_Z:
                if (event.shiftKey) {
                    this.redo();
                } else {
                    this.undo();
                }
                return false;
            case KeyEvent.DOM_VK_Y:
                this.redo();
                return false;
            }
        } else {
            if (event.keyCode === KeyEvent.DOM_VK_LEFT ||
                    event.keyCode === KeyEvent.DOM_VK_RIGHT ||
                    event.keyCode === KeyEvent.DOM_VK_UP ||
                    event.keyCode === KeyEvent.DOM_VK_DOWN) {
                // Prevent arrow keys that weren't handled by a child control
                // from being handled by a parent, either through bubbling or
                // through default native behavior. There isn't a good general
                // way to tell if the target would handle this event by default,
                // so we look to see if the target is a text input control.
                var preventDefault = false,
                    $target = $(event.target);

                // If the input has no "type" attribute, it defaults to text. So we
                // have to check for both possibilities.
                if ($target.is("input:not([type])") || $target.is("input[type=text]")) {
                    // Text input control. In WebKit, if the cursor gets to the start
                    // or end of a text field and can't move any further, the default
                    // action doesn't take place in the text field, so the event is handled
                    // by the outer scroller. We have to prevent in that case too.
                    if ($target[0].selectionStart === $target[0].selectionEnd &&
                            ((event.keyCode === KeyEvent.DOM_VK_LEFT && $target[0].selectionStart === 0) ||
                             (event.keyCode === KeyEvent.DOM_VK_RIGHT && $target[0].selectionEnd === $target.val().length))) {
                        preventDefault = true;
                    }
                } else {
                    // Not a text input control, so we want to prevent default.
                    preventDefault = true;
                }

                if (preventDefault) {
                    event.stopPropagation();
                    return false; // equivalent to event.preventDefault()
                }
            }
        }
    };

    ColorEditor.prototype._handleHslKeydown = function (event) {
        if (event.keyCode === KeyEvent.DOM_VK_TAB) {
            // If we're the last focusable element (no color swatches), Tab wraps around to color square
            if (!event.shiftKey) {
                if (this.$swatches.children().length === 0) {
                    this.$selectionBase.focus();
                    return false;
                }
            }
        }
    };

    /** Key events on the color square's thumb */
    ColorEditor.prototype._handleSelectionKeydown = function (event) {
        var hsv = {},
            step = 1.5,
            xOffset,
            yOffset,
            adjustedOffset;

        switch (event.keyCode) {
        case KeyEvent.DOM_VK_LEFT:
        case KeyEvent.DOM_VK_RIGHT:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            xOffset = Number($.trim(this.$selectionBase[0].style.left.replace("%", "")));
            adjustedOffset = (event.keyCode === KeyEvent.DOM_VK_LEFT) ? (xOffset - step) : (xOffset + step);
            xOffset = Math.min(100, Math.max(0, adjustedOffset));
            hsv.s = xOffset / 100;
            this.setColorAsHsv(hsv, false);
            return false;
        case KeyEvent.DOM_VK_DOWN:
        case KeyEvent.DOM_VK_UP:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            yOffset = Number($.trim(this.$selectionBase[0].style.bottom.replace("%", "")));
            adjustedOffset = (event.keyCode === KeyEvent.DOM_VK_DOWN) ? (yOffset - step) : (yOffset + step);
            yOffset = Math.min(100, Math.max(0, adjustedOffset));
            hsv.v = yOffset / 100;
            this.setColorAsHsv(hsv, false);
            return false;
        case KeyEvent.DOM_VK_TAB:
            // Shift+Tab loops back to last focusable element: last swatch if any; format button bar if not
            if (event.shiftKey) {
                if (this.$swatches.children().length === 0) {
                    this.$hslButton.focus();
                } else {
                    this.$swatches.find("li:last").focus();
                }
                return false;
            }
            break;
        }
    };

    /** Key events on the hue slider thumb */
    ColorEditor.prototype._handleHueKeydown = function (event) {
        var hsv = {},
            hue = Number(this._hsv.h),
            step = 3.6;

        switch (event.keyCode) {
        case KeyEvent.DOM_VK_DOWN:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            hsv.h = (hue - step) <= 0 ? 360 - step : hue - step;
            this.setColorAsHsv(hsv, false);
            return false;
        case KeyEvent.DOM_VK_UP:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            hsv.h = (hue + step) >= 360 ? step : hue + step;
            this.setColorAsHsv(hsv, false);
            return false;
        }
    };

    /** Key events on the opacity slider thumb */
    ColorEditor.prototype._handleOpacityKeydown = function (event) {
        var alpha = this._hsv.a,
            hsv = {},
            step = 0.01;

        switch (event.keyCode) {
        case KeyEvent.DOM_VK_DOWN:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            if (alpha > 0) {
                hsv.a = (alpha - step) <= 0 ? 0 : alpha - step;
                this.setColorAsHsv(hsv);
            }
            return false;
        case KeyEvent.DOM_VK_UP:
            step = event.shiftKey ? step * STEP_MULTIPLIER : step;
            if (alpha < 100) {
                hsv.a = (alpha + step) >= 1 ? 1 : alpha + step;
                this.setColorAsHsv(hsv);
            }
            return false;
        }
    };

    ColorEditor.prototype._bindKeyHandler = function ($element, handler) {
        $element.bind("keydown", handler);
    };

    // Prevent clicks on some UI elements (color selection field, slider and large swatch) from taking focus
    $(window.document).on("mousedown", ".color-selection-field, .slider, .large-swatch", function (e) {
        e.preventDefault();
    });

    exports.ColorEditor = ColorEditor;
});