adobe/brackets

View on GitHub
src/utils/Resizer.js

Summary

Maintainability
D
2 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.
 *
 */

/**
 * Resizer is a Module utility to inject resizing capabilities to any element
 * inside Brackets.
 *
 * On initialization, Resizer discovers all nodes tagged as "vert-resizable"
 * and "horz-resizable" to add the resizer handler. Additionally, "top-resizer",
 * "bottom-resizer", "left-resizer" and "right-resizer" classes control the
 * position of the resizer on the element.
 *
 * An element can be made resizable at any time using the `makeResizable()` API.
 * Panel sizes are saved via preferences and restored when the DOM node becomes resizable
 * again in a subsequent launch.
 *
 * The resizable elements trigger a panelResizeStart, panelResizeUpdate and panelResizeEnd
 * event that can be used to create performance optimizations (such as hiding/showing elements
 * while resizing), custom layout logic, etc. See makeResizable() for details on the events.
 *
 * A resizable element can be collapsed/expanded using the `show`, `hide` and `toggle` APIs or
 * via user action. This triggers panelCollapsed/panelExpanded events - see makeResizable().
 */
define(function (require, exports, module) {
    "use strict";

    var DIRECTION_VERTICAL = "vert";
    var DIRECTION_HORIZONTAL = "horz";

    var POSITION_TOP = "top";
    var POSITION_BOTTOM = "bottom";
    var POSITION_LEFT = "left";
    var POSITION_RIGHT = "right";
    var PREFS_PURE_CODE = "noDistractions";

    // Minimum size (height or width) for autodiscovered resizable panels
    var DEFAULT_MIN_SIZE = 100;

    // Load dependent modules
    var AppInit                 = require("utils/AppInit"),
        EventDispatcher         = require("utils/EventDispatcher"),
        ViewUtils               = require("utils/ViewUtils"),
        PreferencesManager      = require("preferences/PreferencesManager");

    var $mainView,
        $sideBar;

    var isResizing = false,
        isWindowResizing = false;

    /**
     * Shows a resizable element.
     * @param {DOMNode} element Html element to show if possible
     */
    function show(element) {
        var showFunc = $(element).data("show");
        if (showFunc) {
            showFunc.apply(element);
        }
    }

    /**
     * Hides a resizable element.
     * @param {DOMNode} element Html element to hide if possible
     */
    function hide(element) {
        var hideFunc = $(element).data("hide");
        if (hideFunc) {
            hideFunc.apply(element);
        }
    }

    /**
     * Changes the visibility state of a resizable element. The toggle
     * functionality is added when an element is made resizable.
     * @param {DOMNode} element Html element to toggle
     */
    function toggle(element) {
        if ($(element).is(":visible")) {
            hide(element);
        } else {
            show(element);
        }
    }

    /**
     * Removes the resizability of an element if it's resizable
     * @param {DOMNode} element Html element in which to remove sizing
     */
    function removeSizable(element) {
        var removeSizableFunc = $(element).data("removeSizable");
        if (removeSizableFunc) {
            removeSizableFunc.apply(element);
        }
    }

    /**
     * Updates the sizing div by resyncing to the sizing edge of the element
     * Call this method after manually changing the size of the element
     * @param {DOMNode} element Html element whose sizer should be resynchronized
     */
    function resyncSizer(element) {
        var resyncSizerFunc = $(element).data("resyncSizer");
        if (resyncSizerFunc) {
            resyncSizerFunc.apply(element);
        }
    }

    /**
     * Returns the visibility state of a resizable element.
     * @param {DOMNode} element Html element to toggle
     * @return {boolean} true if element is visible, false if it is not visible
     */
    function isVisible(element) {
        return $(element).is(":visible");
    }

    function _isPercentage(value) {
        return !$.isNumeric(value) && value.indexOf('%') > -1;
    }

    function _percentageToPixels(value, total) {
        return parseFloat(value.replace('%', '')) * (total / 100);
    }

    function _sideBarMaxSize() {
        var siblingsWidth = 0;
        $sideBar.siblings().not(".content").each(function (i, elem) {
            var $elem = $(elem);
            if ($elem.css("display") !== "none") {
                siblingsWidth += $elem.outerWidth();
            }
        });
        return $(".main-view").width() - siblingsWidth - 1;
    }

    /**
     * Adds resizing and (optionally) expand/collapse capabilities to a given html element. The element's size
     * & visibility are automatically saved & restored as a view-state preference.
     *
     * Resizing can be configured in two directions:
     *  - Vertical ("vert"): Resizes the height of the element
     *  - Horizontal ("horz"): Resizes the width of the element
     *
     * Resizer handlers can be positioned on the element at:
     *  - Top ("top") or bottom ("bottom") for vertical resizing
     *  - Left ("left") or right ("right") for horizontal resizing
     *
     * A resizable element triggers the following events while resizing:
     *  - panelResizeStart: When the resize starts. Passed the new size.
     *  - panelResizeUpdate: When the resize gets updated. Passed the new size.
     *  - panelResizeEnd: When the resize ends. Passed the final size.
     *  - panelCollapsed: When the panel gets collapsed (or hidden). Passed the last size
     *      before collapse. May occur without any resize events.
     *  - panelExpanded: When the panel gets expanded (or shown). Passed the initial size.
     *      May occur without any resize events.
     *
     * @param {!DOMNode} element DOM element which should be made resizable. Must have an id attribute, for
     *                          use as a preferences key.
     * @param {!string} direction Direction of the resize action: one of the DIRECTION_* constants.
     * @param {!string} position Which side of the element can be dragged: one of the POSITION_* constants
     *                          (TOP/BOTTOM for vertical resizing or LEFT/RIGHT for horizontal).
     * @param {?number} minSize Minimum size (width or height) of the element's outer dimensions, including
     *                          border & padding. Defaults to DEFAULT_MIN_SIZE.
     * @param {?boolean} collapsible Indicates the panel is collapsible on double click on the
     *                          resizer. Defaults to false.
     * @param {?string} forceLeft CSS selector indicating element whose 'left' should be locked to the
     *                          the resizable element's size (useful for siblings laid out to the right of
     *                          the element). Must lie in element's parent's subtree.
     * @param {?boolean} createdByWorkspaceManager For internal use only
     * @param {?boolean} usePercentages Maintain the size of the element as a percentage of its parent
     *                          the default is to maintain the size of the element in pixels
     * @param {?boolean} _attachToParent Attaches the resizer element to parent of the element rather than
     *                          to element itself. Attach the resizer to the parent *ONLY* if element has the
     *                          same offset as parent otherwise the resizer will be incorrectly positioned.
     *                          FOR INTERNAL USE ONLY
     */
    function makeResizable(element, direction, position, minSize, collapsible, forceLeft, createdByWorkspaceManager, usePercentages, _attachToParent) {
        var $resizer            = $('<div class="' + direction + '-resizer"></div>'),
            $element            = $(element),
            $parent             = $element.parent(),
            $resizableElement   = $($element.find(".resizable-content:first")[0]),
            $body               = $(window.document.body),
            elementID           = $element.attr("id"),
            elementPrefs        = PreferencesManager.getViewState(elementID) || {},
            animationRequest    = null,
            directionProperty   = direction === DIRECTION_HORIZONTAL ? "clientX" : "clientY",
            directionIncrement  = (position === POSITION_TOP || position === POSITION_LEFT) ? 1 : -1,
            parentSizeFunction  = direction === DIRECTION_HORIZONTAL ? $parent.innerWidth : $parent.innerHeight,

            elementSizeFunction = function (newSize) {
                if (!newSize) {
                    // calling the function as a getter
                    if (direction === DIRECTION_HORIZONTAL) {
                        return this.width();
                    } else {
                        return this.height();
                    }
                } else if (!usePercentages) {
                    if (direction === DIRECTION_HORIZONTAL) {
                        return this.width(newSize);
                    } else {
                        return this.height(newSize);
                    }
                } else {
                    // calling the function as a setter
                    var parentSize = parentSizeFunction.apply($parent),
                        percentage,
                        prop;

                    if (direction === DIRECTION_HORIZONTAL) {
                        prop = "width";
                    } else {
                        prop = "height";
                    }
                    percentage = newSize / parentSize;
                    this.css(prop, (percentage * 100) + "%");

                    return this; // chainable
                }
            },

            resizerCSSPosition  = direction === DIRECTION_HORIZONTAL ? "left" : "top",
            contentSizeFunction = direction === DIRECTION_HORIZONTAL ? $resizableElement.width : $resizableElement.height;

        if (PreferencesManager.get(PREFS_PURE_CODE) &&
                ($element.hasClass("bottom-panel") || $element.hasClass("sidebar"))) {
            elementPrefs.visible = false;
        }

        if (!elementID) {
            console.error("Resizable panels must have a DOM id to use as a preferences key:", element);
            return;
        }
        // Detect legacy cases where panels in the editor area are created without using WorkspaceManager APIs
        if ($parent[0] && $parent.is(".content") && !createdByWorkspaceManager) {
            console.error("Resizable panels within the editor area should be created via WorkspaceManager.createBottomPanel(). \nElement:", element);
            return;
        }

        if (minSize === undefined) {
            minSize = DEFAULT_MIN_SIZE;
        }

        collapsible = collapsible || false;

        if (_attachToParent) {
            $parent.prepend($resizer);
        } else {
            $element.prepend($resizer);
        }
        // Important so min/max sizes behave predictably
        $element.css("box-sizing", "border-box");

        function adjustSibling(size) {
            if (forceLeft !== undefined) {
                $(forceLeft, $parent).css("left", size);
            }
        }

        function resizeElement(elementSize, contentSize) {
            elementSizeFunction.apply($element, [elementSize]);

            if ($resizableElement.length) {
                contentSizeFunction.apply($resizableElement, [contentSize]);
            }
        }

        // If the resizer is positioned right or bottom of the panel, we need to listen to
        // reposition it if the element size changes externally
        function repositionResizer(elementSize) {
            var resizerPosition = elementSize || 1;
            if (position === POSITION_RIGHT || position === POSITION_BOTTOM) {
                $resizer.css(resizerCSSPosition, resizerPosition);
            }
        }

        $element.data("removeSizable", function () {
            $resizer.off(".resizer");

            $element.removeData("show");
            $element.removeData("hide");
            $element.removeData("resyncSizer");
            $element.removeData("removeSizable");

            $resizer.remove();
        });

        $element.data("resyncSizer", function () {
            repositionResizer(elementSizeFunction.apply($element));
        });

        $element.data("show", function () {
            var elementOffset   = $element.offset(),
                elementSize     = elementSizeFunction.apply($element) || elementPrefs.size,
                contentSize     = contentSizeFunction.apply($resizableElement) || elementPrefs.contentSize;

            // Resize the element before showing it again. If the panel was collapsed by dragging
            // the resizer, the size of the element should be 0, so we restore size in preferences
            resizeElement(elementSize, contentSize);

            $element.show();
            elementPrefs.visible = true;

            if (collapsible) {
                if (_attachToParent) {
                    $parent.prepend($resizer);
                } else {
                    $element.prepend($resizer);
                }
                if (position === POSITION_TOP) {
                    $resizer.css(resizerCSSPosition, "");
                } else if (position === POSITION_RIGHT) {
                    $resizer.css(resizerCSSPosition, elementOffset[resizerCSSPosition] + elementSize);
                }
            }

            adjustSibling(elementSize);

            $element.trigger("panelExpanded", [elementSize]);
            PreferencesManager.setViewState(elementID, elementPrefs, null, isResizing);
        });

        $element.data("hide", function () {
            var elementOffset   = $element.offset(),
                elementSize     = elementSizeFunction.apply($element),
                resizerSize     = elementSizeFunction.apply($resizer);

            $element.hide();
            elementPrefs.visible = false;
            if (collapsible) {
                $resizer.insertBefore($element);
                if (position === POSITION_RIGHT) {
                    $resizer.css(resizerCSSPosition, "");
                } else if (position === POSITION_TOP) {
                    $resizer.css(resizerCSSPosition, elementOffset[resizerCSSPosition] + elementSize - resizerSize);
                }
            }

            adjustSibling(0);

            $element.trigger("panelCollapsed", [elementSize]);
            PreferencesManager.setViewState(elementID, elementPrefs, null, isResizing);
        });


        $resizer.on("mousedown.resizer", function (e) {
            var $resizeShield   = $("<div class='resizing-container " + direction + "-resizing' />"),
                startPosition   = e[directionProperty],
                startSize       = $element.is(":visible") ? elementSizeFunction.apply($element) : 0,
                newSize         = startSize,
                previousSize    = startSize,
                baseSize        = 0,
                resizeStarted   = false;

            isResizing = true;
            $body.append($resizeShield);

            if ($resizableElement.length) {
                $element.children().not(".horz-resizer, .vert-resizer, .resizable-content").each(function (index, child) {
                    if (direction === DIRECTION_HORIZONTAL) {
                        baseSize += $(child).outerWidth();
                    } else {
                        baseSize += $(child).outerHeight();
                    }
                });
            }

            function doRedraw() {
                // only run this if the mouse is down so we don't constantly loop even
                // after we're done resizing.
                if (!isResizing) {
                    return;
                }

                // Check for real size changes to avoid unnecessary resizing and events
                if (newSize !== previousSize) {
                    previousSize = newSize;

                    if ($element.is(":visible")) {
                        if (newSize < 10) {
                            toggle($element);
                            elementSizeFunction.apply($element, [0]);
                        } else {
                            // Trigger resizeStarted just before the first successful resize update
                            if (!resizeStarted) {
                                resizeStarted = true;
                                $element.trigger("panelResizeStart", newSize);
                            }

                            // Resize the main element to the new size. If there is a content element,
                            // its size is the new size minus the size of the non-resizable elements
                            resizeElement(newSize, (newSize - baseSize));
                            adjustSibling(newSize);

                            $element.trigger("panelResizeUpdate", [newSize]);
                        }
                    } else if (newSize > 10) {
                        elementSizeFunction.apply($element, [newSize]);
                        toggle($element);

                        // Trigger resizeStarted after expanding the element if it was previously collapsed
                        if (!resizeStarted) {
                            resizeStarted = true;
                            $element.trigger("panelResizeStart", newSize);
                        }
                    }
                }

                animationRequest = window.requestAnimationFrame(doRedraw);
            }

            function onMouseMove(e) {
                // calculate newSize adding to startSize the difference
                // between starting and current position, capped at minSize
                newSize = Math.max(startSize + directionIncrement * (startPosition - e[directionProperty]), minSize);

                // respect max size if one provided (e.g. by WorkspaceManager)
                var maxSize = $element.data("maxsize");
                if (maxSize !== undefined) {
                    // if provided as percentage size convert it to a pixel size
                    if (_isPercentage(maxSize)) {
                        maxSize = _percentageToPixels(maxSize, _sideBarMaxSize());
                    }
                    newSize = Math.min(newSize, maxSize);
                }

                e.preventDefault();

                if (animationRequest === null) {
                    animationRequest = window.requestAnimationFrame(doRedraw);
                }
            }

            $(window.document).on("mousemove", onMouseMove);

            // If the element is marked as collapsible, check for double click
            // to toggle the element visibility
            if (collapsible) {
                $resizeShield.on("mousedown", function (e) {
                    $(window.document).off("mousemove", onMouseMove);
                    $resizeShield.off("mousedown");
                    $resizeShield.remove();
                    animationRequest = null;
                    toggle($element);
                });
            }

            function endResize(e) {
                if (isResizing) {

                    var elementSize    = elementSizeFunction.apply($element);
                    if ($element.is(":visible")) {
                        elementPrefs.size = elementSize;
                        if ($resizableElement.length) {
                            elementPrefs.contentSize = contentSizeFunction.apply($resizableElement);
                        }
                        PreferencesManager.setViewState(elementID, elementPrefs);
                        repositionResizer(elementSize);
                    }

                    isResizing = false;

                    if (resizeStarted) {
                        $element.trigger("panelResizeEnd", [elementSize]);
                    }

                    // We wait 300ms to remove the resizer container to capture a mousedown
                    // on the container that would account for double click
                    window.setTimeout(function () {
                        $(window.document).off("mousemove", onMouseMove);
                        $resizeShield.off("mousedown");
                        $resizeShield.remove();
                        animationRequest = null;
                    }, 300);
                }
            }

            $(window.document).one("mouseup", endResize);

            e.preventDefault();
        });

        // Panel preferences initialization
        if (elementPrefs) {

            if (elementPrefs.size !== undefined) {
                elementSizeFunction.apply($element, [elementPrefs.size]);
            }

            if (elementPrefs.contentSize !== undefined) {
                contentSizeFunction.apply($resizableElement, [elementPrefs.contentSize]);
            }

            if (elementPrefs.visible !== undefined && !elementPrefs.visible) {
                hide($element);
            } else {
                adjustSibling(elementSizeFunction.apply($element));
                repositionResizer(elementSizeFunction.apply($element));
            }
        }
    }

    function updateResizeLimits() {
        var sideBarMaxSize = _sideBarMaxSize(),
            maxSize = $sideBar.data("maxsize"),
            width = false;

        if (maxSize !== undefined && _isPercentage(maxSize)) {
            sideBarMaxSize = _percentageToPixels(maxSize, sideBarMaxSize);
        }

        if ($sideBar.width() > sideBarMaxSize) {
            // Adjust the sideBar's width in case it exceeds the window's width when resizing the window.
            $sideBar.width(sideBarMaxSize);
            resyncSizer($sideBar);
            $(".content").css("left", $sideBar.width());
            $sideBar.trigger("panelResizeStart", $sideBar.width());
            $sideBar.trigger("panelResizeUpdate", [$sideBar.width()]);
            $sideBar.trigger("panelResizeEnd", [$sideBar.width()]);
        }
    }

    function onWindowResize(e) {
        if ($sideBar.css("display") === "none") {
            return;
        }

        if (!isWindowResizing) {
            isWindowResizing = true;

            // We don't need any fancy debouncing here - we just need to react before the user can start
            // resizing any panels at the new window size. So just listen for first mousemove once the
            // window resize releases mouse capture.
            $(window.document).one("mousemove", function () {
                isWindowResizing = false;
                updateResizeLimits();
            });
        }
    }

    window.addEventListener("resize", onWindowResize, true);

    // Scan DOM for horz-resizable and vert-resizable classes and make them resizable
    AppInit.htmlReady(function () {
        var minSize = DEFAULT_MIN_SIZE;

        $mainView = $(".main-view");
        $sideBar = $("#sidebar");

        $(".vert-resizable").each(function (index, element) {

            if ($(element).data().minsize !== undefined) {
                minSize = $(element).data().minsize;
            }

            if ($(element).hasClass("top-resizer")) {
                makeResizable(element, DIRECTION_VERTICAL, POSITION_TOP, minSize, $(element).hasClass("collapsible"));
            }

            //if ($(element).hasClass("bottom-resizer")) {
            //    makeResizable(element, DIRECTION_VERTICAL, POSITION_BOTTOM, DEFAULT_MIN_SIZE);
            //}
        });

        $(".horz-resizable").each(function (index, element) {

            if ($(element).data().minsize !== undefined) {
                minSize = $(element).data().minsize;
            }

            //if ($(element).hasClass("left-resizer")) {
            //    makeResizable(element, DIRECTION_HORIZONTAL, POSITION_LEFT, DEFAULT_MIN_SIZE);
            //}

            if ($(element).hasClass("right-resizer")) {
                makeResizable(element, DIRECTION_HORIZONTAL, POSITION_RIGHT, minSize, $(element).hasClass("collapsible"), $(element).data().forceleft);
            }
        });

        // The main toolbar is only collapsible.
        if ($("#main-toolbar").hasClass("collapsible") && PreferencesManager.get(PREFS_PURE_CODE)) {
            ViewUtils.hideMainToolBar();
        }
    });

    EventDispatcher.makeEventDispatcher(exports);

    exports.makeResizable   = makeResizable;
    exports.removeSizable   = removeSizable;
    exports.resyncSizer     = resyncSizer;
    exports.toggle          = toggle;
    exports.show            = show;
    exports.hide            = hide;
    exports.isVisible       = isVisible;

    //Resizer Constants
    exports.DIRECTION_VERTICAL   = DIRECTION_VERTICAL;
    exports.DIRECTION_HORIZONTAL = DIRECTION_HORIZONTAL;
    exports.POSITION_TOP         = POSITION_TOP;
    exports.POSITION_RIGHT       = POSITION_RIGHT;
    exports.POSITION_BOTTOM      = POSITION_BOTTOM;
    exports.POSITION_LEFT        = POSITION_LEFT;
});