mozilla/webmaker.org

View on GitHub
public/js/lib/jquery.powertip.js

Summary

Maintainability
F
5 days
Test Coverage
/*!
 PowerTip - v1.2.0 - 2013-04-03
 http://stevenbenner.github.com/jquery-powertip/
 Copyright (c) 2013 Steven Benner (http://stevenbenner.com/).
 Released under MIT license.
 https://raw.github.com/stevenbenner/jquery-powertip/master/LICENSE.txt
*/
(function(factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['jquery'], factory);
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function($) {

    // useful private variables
    var $document = $(document),
        $window = $(window),
        $body = $('body');

    // constants
    var DATA_DISPLAYCONTROLLER = 'displayController',
        DATA_HASACTIVEHOVER = 'hasActiveHover',
        DATA_FORCEDOPEN = 'forcedOpen',
        DATA_HASMOUSEMOVE = 'hasMouseMove',
        DATA_MOUSEONTOTIP = 'mouseOnToPopup',
        DATA_ORIGINALTITLE = 'originalTitle',
        DATA_POWERTIP = 'powertip',
        DATA_POWERTIPJQ = 'powertipjq',
        DATA_POWERTIPTARGET = 'powertiptarget',
        RAD2DEG = 180 / Math.PI;

    /**
     * Session data
     * Private properties global to all powerTip instances
     */
    var session = {
        isTipOpen: false,
        isFixedTipOpen: false,
        isClosing: false,
        tipOpenImminent: false,
        activeHover: null,
        currentX: 0,
        currentY: 0,
        previousX: 0,
        previousY: 0,
        desyncTimeout: null,
        mouseTrackingActive: false,
        delayInProgress: false,
        windowWidth: 0,
        windowHeight: 0,
        scrollTop: 0,
        scrollLeft: 0
    };

    /**
     * Collision enumeration
     * @enum {number}
     */
    var Collision = {
        none: 0,
        top: 1,
        bottom: 2,
        left: 4,
        right: 8
    };

    /**
     * Display hover tooltips on the matched elements.
     * @param {(Object|string)} opts The options object to use for the plugin, or
     *     the name of a method to invoke on the first matched element.
     * @param {*=} [arg] Argument for an invoked method (optional).
     * @return {jQuery} jQuery object for the matched selectors.
     */
    $.fn.powerTip = function(opts, arg) {
        // don't do any work if there were no matched elements
        if (!this.length) {
            return this;
        }

        // handle api method calls on the plugin, e.g. powerTip('hide')
        if ($.type(opts) === 'string' && $.powerTip[opts]) {
            return $.powerTip[opts].call(this, this, arg);
        }

        // extend options and instantiate TooltipController
        var options = $.extend({}, $.fn.powerTip.defaults, opts),
            tipController = new TooltipController(options);

        // hook mouse and viewport dimension tracking
        initTracking();

        // setup the elements
        this.each(function elementSetup() {
            var $this = $(this),
                dataPowertip = $this.data(DATA_POWERTIP),
                dataElem = $this.data(DATA_POWERTIPJQ),
                dataTarget = $this.data(DATA_POWERTIPTARGET),
                title;

            // handle repeated powerTip calls on the same element by destroying the
            // original instance hooked to it and replacing it with this call
            if ($this.data(DATA_DISPLAYCONTROLLER)) {
                $.powerTip.destroy($this);
            }

            // attempt to use title attribute text if there is no data-powertip,
            // data-powertipjq or data-powertiptarget. If we do use the title
            // attribute, delete the attribute so the browser will not show it
            title = $this.attr('title');
            if (!dataPowertip && !dataTarget && !dataElem && title) {
                $this.data(DATA_POWERTIP, title);
                $this.data(DATA_ORIGINALTITLE, title);
                $this.removeAttr('title');
            }

            // create hover controllers for each element
            $this.data(
                DATA_DISPLAYCONTROLLER,
                new DisplayController($this, options, tipController)
            );
        });

        // attach events to matched elements if the manual options is not enabled
        if (!options.manual) {
            this.on({
                // mouse events
                'mouseenter.powertip': function elementMouseEnter(event) {
                    $.powerTip.show(this, event);
                },
                'mouseleave.powertip': function elementMouseLeave() {
                    $.powerTip.hide(this);
                },
                // keyboard events
                'focus.powertip': function elementFocus() {
                    $.powerTip.show(this);
                },
                'blur.powertip': function elementBlur() {
                    $.powerTip.hide(this, true);
                },
                'keydown.powertip': function elementKeyDown(event) {
                    // close tooltip when the escape key is pressed
                    if (event.keyCode === 27) {
                        $.powerTip.hide(this, true);
                    }
                }
            });
        }

        return this;
    };

    /**
     * Default options for the powerTip plugin.
     */
    $.fn.powerTip.defaults = {
        fadeInTime: 200,
        fadeOutTime: 100,
        followMouse: false,
        popupId: 'powerTip',
        intentSensitivity: 7,
        intentPollInterval: 100,
        closeDelay: 100,
        placement: 'n',
        smartPlacement: false,
        offset: 10,
        mouseOnToPopup: false,
        manual: false
    };

    /**
     * Default smart placement priority lists.
     * The first item in the array is the highest priority, the last is the lowest.
     * The last item is also the default, which will be used if all previous options
     * do not fit.
     */
    $.fn.powerTip.smartPlacementLists = {
        n: ['n', 'ne', 'nw', 's'],
        e: ['e', 'ne', 'se', 'w', 'nw', 'sw', 'n', 's', 'e'],
        s: ['s', 'se', 'sw', 'n'],
        w: ['w', 'nw', 'sw', 'e', 'ne', 'se', 'n', 's', 'w'],
        nw: ['nw', 'w', 'sw', 'n', 's', 'se', 'nw'],
        ne: ['ne', 'e', 'se', 'n', 's', 'sw', 'ne'],
        sw: ['sw', 'w', 'nw', 's', 'n', 'ne', 'sw'],
        se: ['se', 'e', 'ne', 's', 'n', 'nw', 'se'],
        'nw-alt': ['nw-alt', 'n', 'ne-alt', 'sw-alt', 's', 'se-alt', 'w', 'e'],
        'ne-alt': ['ne-alt', 'n', 'nw-alt', 'se-alt', 's', 'sw-alt', 'e', 'w'],
        'sw-alt': ['sw-alt', 's', 'se-alt', 'nw-alt', 'n', 'ne-alt', 'w', 'e'],
        'se-alt': ['se-alt', 's', 'sw-alt', 'ne-alt', 'n', 'nw-alt', 'e', 'w']
    };

    /**
     * Public API
     */
    $.powerTip = {
        /**
         * Attempts to show the tooltip for the specified element.
         * @param {jQuery|Element} element The element to open the tooltip for.
         * @param {jQuery.Event=} event jQuery event for hover intent and mouse
         *     tracking (optional).
         */
        show: function apiShowTip(element, event) {
            if (event) {
                trackMouse(event);
                session.previousX = event.pageX;
                session.previousY = event.pageY;
                $(element).data(DATA_DISPLAYCONTROLLER).show();
            } else {
                $(element).first().data(DATA_DISPLAYCONTROLLER).show(true, true);
            }
            return element;
        },

        /**
         * Repositions the tooltip on the element.
         * @param {jQuery|Element} element The element the tooltip is shown for.
         */
        reposition: function apiResetPosition(element) {
            $(element).first().data(DATA_DISPLAYCONTROLLER).resetPosition();
            return element;
        },

        /**
         * Attempts to close any open tooltips.
         * @param {(jQuery|Element)=} element The element with the tooltip that
         *     should be closed (optional).
         * @param {boolean=} immediate Disable close delay (optional).
         */
        hide: function apiCloseTip(element, immediate) {
            if (element) {
                $(element).first().data(DATA_DISPLAYCONTROLLER).hide(immediate);
            } else {
                if (session.activeHover) {
                    session.activeHover.data(DATA_DISPLAYCONTROLLER).hide(true);
                }
            }
            return element;
        },

        /**
         * Destroy and roll back any powerTip() instance on the specified element.
         * @param {jQuery|Element} element The element with the powerTip instance.
         */
        destroy: function apiDestroy(element) {
            $(element).off('.powertip').each(function destroy() {
                var $this = $(this),
                    dataAttributes = [
                        DATA_ORIGINALTITLE,
                        DATA_DISPLAYCONTROLLER,
                        DATA_HASACTIVEHOVER,
                        DATA_FORCEDOPEN
                    ];

                if ($this.data(DATA_ORIGINALTITLE)) {
                    $this.attr('title', $this.data(DATA_ORIGINALTITLE));
                    dataAttributes.push(DATA_POWERTIP);
                }

                $this.removeData(dataAttributes);
            });
            return element;
        }
    };

    // API aliasing
    $.powerTip.showTip = $.powerTip.show;
    $.powerTip.closeTip = $.powerTip.hide;

    /**
     * Creates a new CSSCoordinates object.
     * @private
     * @constructor
     */
    function CSSCoordinates() {
        var me = this;

        // initialize object properties
        me.top = 'auto';
        me.left = 'auto';
        me.right = 'auto';
        me.bottom = 'auto';

        /**
         * Set a property to a value.
         * @private
         * @param {string} property The name of the property.
         * @param {number} value The value of the property.
         */
        me.set = function(property, value) {
            if ($.isNumeric(value)) {
                me[property] = Math.round(value);
            }
        };
    }

    /**
     * Creates a new tooltip display controller.
     * @private
     * @constructor
     * @param {jQuery} element The element that this controller will handle.
     * @param {Object} options Options object containing settings.
     * @param {TooltipController} tipController The TooltipController object for
     *     this instance.
     */
    function DisplayController(element, options, tipController) {
        var hoverTimer = null;

        /**
         * Begins the process of showing a tooltip.
         * @private
         * @param {boolean=} immediate Skip intent testing (optional).
         * @param {boolean=} forceOpen Ignore cursor position and force tooltip to
         *     open (optional).
         */
        function openTooltip(immediate, forceOpen) {
            cancelTimer();
            if (!element.data(DATA_HASACTIVEHOVER)) {
                if (!immediate) {
                    session.tipOpenImminent = true;
                    hoverTimer = setTimeout(
                        function intentDelay() {
                            hoverTimer = null;
                            checkForIntent();
                        },
                        options.intentPollInterval
                    );
                } else {
                    if (forceOpen) {
                        element.data(DATA_FORCEDOPEN, true);
                    }
                    tipController.showTip(element);
                }
            }
        }

        /**
         * Begins the process of closing a tooltip.
         * @private
         * @param {boolean=} disableDelay Disable close delay (optional).
         */
        function closeTooltip(disableDelay) {
            cancelTimer();
            session.tipOpenImminent = false;
            if (element.data(DATA_HASACTIVEHOVER)) {
                element.data(DATA_FORCEDOPEN, false);
                if (!disableDelay) {
                    session.delayInProgress = true;
                    hoverTimer = setTimeout(
                        function closeDelay() {
                            hoverTimer = null;
                            tipController.hideTip(element);
                            session.delayInProgress = false;
                        },
                        options.closeDelay
                    );
                } else {
                    tipController.hideTip(element);
                }
            }
        }

        /**
         * Checks mouse position to make sure that the user intended to hover on the
         * specified element before showing the tooltip.
         * @private
         */
        function checkForIntent() {
            // calculate mouse position difference
            var xDifference = Math.abs(session.previousX - session.currentX),
                yDifference = Math.abs(session.previousY - session.currentY),
                totalDifference = xDifference + yDifference;

            // check if difference has passed the sensitivity threshold
            if (totalDifference < options.intentSensitivity) {
                tipController.showTip(element);
            } else {
                // try again
                session.previousX = session.currentX;
                session.previousY = session.currentY;
                openTooltip();
            }
        }

        /**
         * Cancels active hover timer.
         * @private
         */
        function cancelTimer() {
            hoverTimer = clearTimeout(hoverTimer);
            session.delayInProgress = false;
        }

        /**
         * Repositions the tooltip on this element.
         * @private
         */
        function repositionTooltip() {
            tipController.resetPosition(element);
        }

        // expose the methods
        this.show = openTooltip;
        this.hide = closeTooltip;
        this.cancel = cancelTimer;
        this.resetPosition = repositionTooltip;
    }

    /**
     * Creates a new Placement Calculator.
     * @private
     * @constructor
     */
    function PlacementCalculator() {
        /**
         * Compute the CSS position to display a tooltip at the specified placement
         * relative to the specified element.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         * @param {string} placement The placement for the tooltip.
         * @param {number} tipWidth Width of the tooltip element in pixels.
         * @param {number} tipHeight Height of the tooltip element in pixels.
         * @param {number} offset Distance to offset tooltips in pixels.
         * @return {CSSCoordinates} A CSSCoordinates object with the position.
         */
        function computePlacementCoords(element, placement, tipWidth, tipHeight, offset) {
            var placementBase = placement.split('-')[0], // ignore 'alt' for corners
                coords = new CSSCoordinates(),
                position;

            if (isSvgElement(element)) {
                position = getSvgPlacement(element, placementBase);
            } else {
                position = getHtmlPlacement(element, placementBase);
            }

            // calculate the appropriate x and y position in the document
            switch (placement) {
            case 'n':
                coords.set('left', position.left - (tipWidth / 2));
                coords.set('bottom', session.windowHeight - position.top + offset);
                break;
            case 'e':
                coords.set('left', position.left + offset);
                coords.set('top', position.top - (tipHeight / 2));
                break;
            case 's':
                coords.set('left', position.left - (tipWidth / 2));
                coords.set('top', position.top + offset);
                break;
            case 'w':
                coords.set('top', position.top - (tipHeight / 2));
                coords.set('right', session.windowWidth - position.left + offset);
                break;
            case 'nw':
                coords.set('bottom', session.windowHeight - position.top + offset);
                coords.set('right', session.windowWidth - position.left - 20);
                break;
            case 'nw-alt':
                coords.set('left', position.left);
                coords.set('bottom', session.windowHeight - position.top + offset);
                break;
            case 'ne':
                coords.set('left', position.left - 20);
                coords.set('bottom', session.windowHeight - position.top + offset);
                break;
            case 'ne-alt':
                coords.set('bottom', session.windowHeight - position.top + offset);
                coords.set('right', session.windowWidth - position.left);
                break;
            case 'sw':
                coords.set('top', position.top + offset);
                coords.set('right', session.windowWidth - position.left - 20);
                break;
            case 'sw-alt':
                coords.set('left', position.left);
                coords.set('top', position.top + offset);
                break;
            case 'se':
                coords.set('left', position.left - 20);
                coords.set('top', position.top + offset);
                break;
            case 'se-alt':
                coords.set('top', position.top + offset);
                coords.set('right', session.windowWidth - position.left);
                break;
            }

            return coords;
        }

        /**
         * Finds the tooltip attachment point in the document for a HTML DOM element
         * for the specified placement.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         * @param {string} placement The placement for the tooltip.
         * @return {Object} An object with the top,left position values.
         */
        function getHtmlPlacement(element, placement) {
            var objectOffset = element.offset(),
                objectWidth = element.outerWidth(),
                objectHeight = element.outerHeight(),
                left,
                top;

            // calculate the appropriate x and y position in the document
            switch (placement) {
            case 'n':
                left = objectOffset.left + objectWidth / 2;
                top = objectOffset.top;
                break;
            case 'e':
                left = objectOffset.left + objectWidth;
                top = objectOffset.top + objectHeight / 2;
                break;
            case 's':
                left = objectOffset.left + objectWidth / 2;
                top = objectOffset.top + objectHeight;
                break;
            case 'w':
                left = objectOffset.left;
                top = objectOffset.top + objectHeight / 2;
                break;
            case 'nw':
                left = objectOffset.left;
                top = objectOffset.top;
                break;
            case 'ne':
                left = objectOffset.left + objectWidth;
                top = objectOffset.top;
                break;
            case 'sw':
                left = objectOffset.left;
                top = objectOffset.top + objectHeight;
                break;
            case 'se':
                left = objectOffset.left + objectWidth;
                top = objectOffset.top + objectHeight;
                break;
            }

            return {
                top: top,
                left: left
            };
        }

        /**
         * Finds the tooltip attachment point in the document for a SVG element for
         * the specified placement.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         * @param {string} placement The placement for the tooltip.
         * @return {Object} An object with the top,left position values.
         */
        function getSvgPlacement(element, placement) {
            var svgElement = element.closest('svg')[0],
                domElement = element[0],
                point = svgElement.createSVGPoint(),
                boundingBox = domElement.getBBox(),
                matrix = domElement.getScreenCTM(),
                halfWidth = boundingBox.width / 2,
                halfHeight = boundingBox.height / 2,
                placements = [],
                placementKeys = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'],
                coords,
                rotation,
                steps,
                x;

            function pushPlacement() {
                placements.push(point.matrixTransform(matrix));
            }

            // get bounding box corners and midpoints
            point.x = boundingBox.x;
            point.y = boundingBox.y;
            pushPlacement();
            point.x += halfWidth;
            pushPlacement();
            point.x += halfWidth;
            pushPlacement();
            point.y += halfHeight;
            pushPlacement();
            point.y += halfHeight;
            pushPlacement();
            point.x -= halfWidth;
            pushPlacement();
            point.x -= halfWidth;
            pushPlacement();
            point.y -= halfHeight;
            pushPlacement();

            // determine rotation
            if (placements[0].y !== placements[1].y || placements[0].x !== placements[7].x) {
                rotation = Math.atan2(matrix.b, matrix.a) * RAD2DEG;
                steps = Math.ceil(((rotation % 360) - 22.5) / 45);
                if (steps < 1) {
                    steps += 8;
                }
                while (steps--) {
                    placementKeys.push(placementKeys.shift());
                }
            }

            // find placement
            for (x = 0; x < placements.length; x++) {
                if (placementKeys[x] === placement) {
                    coords = placements[x];
                    break;
                }
            }

            return {
                top: coords.y + session.scrollTop,
                left: coords.x + session.scrollLeft
            };
        }

        // expose methods
        this.compute = computePlacementCoords;
    }

    /**
     * Creates a new tooltip controller.
     * @private
     * @constructor
     * @param {Object} options Options object containing settings.
     */
    function TooltipController(options) {
        var placementCalculator = new PlacementCalculator(),
            tipElement = $('#' + options.popupId);

        // build and append tooltip div if it does not already exist
        if (tipElement.length === 0) {
            tipElement = $('<div/>', { id: options.popupId });
            // grab body element if it was not populated when the script loaded
            // note: this hack exists solely for jsfiddle support
            if ($body.length === 0) {
                $body = $('body');
            }
            $body.append(tipElement);
        }

        // hook mousemove for cursor follow tooltips
        if (options.followMouse) {
            // only one positionTipOnCursor hook per tooltip element, please
            if (!tipElement.data(DATA_HASMOUSEMOVE)) {
                $document.on('mousemove', positionTipOnCursor);
                $window.on('scroll', positionTipOnCursor);
                tipElement.data(DATA_HASMOUSEMOVE, true);
            }
        }

        // if we want to be able to mouse onto the tooltip then we need to attach
        // hover events to the tooltip that will cancel a close request on hover and
        // start a new close request on mouseleave
        if (options.mouseOnToPopup) {
            tipElement.on({
                mouseenter: function tipMouseEnter() {
                    // we only let the mouse stay on the tooltip if it is set to let
                    // users interact with it
                    if (tipElement.data(DATA_MOUSEONTOTIP)) {
                        // check activeHover in case the mouse cursor entered the
                        // tooltip during the fadeOut and close cycle
                        if (session.activeHover) {
                            session.activeHover.data(DATA_DISPLAYCONTROLLER).cancel();
                        }
                    }
                },
                mouseleave: function tipMouseLeave() {
                    // check activeHover in case the mouse cursor entered the
                    // tooltip during the fadeOut and close cycle
                    if (session.activeHover) {
                        session.activeHover.data(DATA_DISPLAYCONTROLLER).hide();
                    }
                }
            });
        }

        /**
         * Gives the specified element the active-hover state and queues up the
         * showTip function.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         */
        function beginShowTip(element) {
            element.data(DATA_HASACTIVEHOVER, true);
            // show tooltip, asap
            tipElement.queue(function queueTipInit(next) {
                showTip(element);
                next();
            });
        }

        /**
         * Shows the tooltip, as soon as possible.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         */
        function showTip(element) {
            var tipContent;

            // it is possible, especially with keyboard navigation, to move on to
            // another element with a tooltip during the queue to get to this point
            // in the code. if that happens then we need to not proceed or we may
            // have the fadeout callback for the last tooltip execute immediately
            // after this code runs, causing bugs.
            if (!element.data(DATA_HASACTIVEHOVER)) {
                return;
            }

            // if the tooltip is open and we got asked to open another one then the
            // old one is still in its fadeOut cycle, so wait and try again
            if (session.isTipOpen) {
                if (!session.isClosing) {
                    hideTip(session.activeHover);
                }
                tipElement.delay(100).queue(function queueTipAgain(next) {
                    showTip(element);
                    next();
                });
                return;
            }

            // trigger powerTipPreRender event
            element.trigger('powerTipPreRender');

            // set tooltip content
            tipContent = getTooltipContent(element);
            if (tipContent) {
                tipElement.empty().append(tipContent);
            } else {
                // we have no content to display, give up
                return;
            }

            // trigger powerTipRender event
            element.trigger('powerTipRender');

            session.activeHover = element;
            session.isTipOpen = true;

            tipElement.data(DATA_MOUSEONTOTIP, options.mouseOnToPopup);

            // set tooltip position
            if (!options.followMouse) {
                positionTipOnElement(element);
                session.isFixedTipOpen = true;
            } else {
                positionTipOnCursor();
            }

            // fadein
            tipElement.fadeIn(options.fadeInTime, function fadeInCallback() {
                // start desync polling
                if (!session.desyncTimeout) {
                    session.desyncTimeout = setInterval(closeDesyncedTip, 500);
                }

                // trigger powerTipOpen event
                element.trigger('powerTipOpen');
            });
        }

        /**
         * Hides the tooltip.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         */
        function hideTip(element) {
            // reset session
            session.isClosing = true;
            session.activeHover = null;
            session.isTipOpen = false;

            // stop desync polling
            session.desyncTimeout = clearInterval(session.desyncTimeout);

            // reset element state
            element.data(DATA_HASACTIVEHOVER, false);
            element.data(DATA_FORCEDOPEN, false);

            // fade out
            tipElement.fadeOut(options.fadeOutTime, function fadeOutCallback() {
                var coords = new CSSCoordinates();

                // reset session and tooltip element
                session.isClosing = false;
                session.isFixedTipOpen = false;
                tipElement.removeClass();

                // support mouse-follow and fixed position tips at the same time by
                // moving the tooltip to the last cursor location after it is hidden
                coords.set('top', session.currentY + options.offset);
                coords.set('left', session.currentX + options.offset);
                tipElement.css(coords);

                // trigger powerTipClose event
                element.trigger('powerTipClose');
            });
        }

        /**
         * Moves the tooltip to the users mouse cursor.
         * @private
         */
        function positionTipOnCursor() {
            // to support having fixed tooltips on the same page as cursor tooltips,
            // where both instances are referencing the same tooltip element, we
            // need to keep track of the mouse position constantly, but we should
            // only set the tip location if a fixed tip is not currently open, a tip
            // open is imminent or active, and the tooltip element in question does
            // have a mouse-follow using it.
            if (!session.isFixedTipOpen && (session.isTipOpen || (session.tipOpenImminent && tipElement.data(DATA_HASMOUSEMOVE)))) {
                // grab measurements
                var tipWidth = tipElement.outerWidth(),
                    tipHeight = tipElement.outerHeight(),
                    coords = new CSSCoordinates(),
                    collisions,
                    collisionCount;

                // grab collisions
                coords.set('top', session.currentY + options.offset);
                coords.set('left', session.currentX + options.offset);
                collisions = getViewportCollisions(
                    coords,
                    tipWidth,
                    tipHeight
                );

                // handle tooltip view port collisions
                if (collisions !== Collision.none) {
                    collisionCount = countFlags(collisions);
                    if (collisionCount === 1) {
                        // if there is only one collision (bottom or right) then
                        // simply constrain the tooltip to the view port
                        if (collisions === Collision.right) {
                            coords.set('left', session.windowWidth - tipWidth);
                        } else if (collisions === Collision.bottom) {
                            coords.set('top', session.scrollTop + session.windowHeight - tipHeight);
                        }
                    } else {
                        // if the tooltip has more than one collision then it is
                        // trapped in the corner and should be flipped to get it out
                        // of the users way
                        coords.set('left', session.currentX - tipWidth - options.offset);
                        coords.set('top', session.currentY - tipHeight - options.offset);
                    }
                }

                // position the tooltip
                tipElement.css(coords);
            }
        }

        /**
         * Sets the tooltip to the correct position relative to the specified target
         * element. Based on options settings.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         */
        function positionTipOnElement(element) {
            var priorityList,
                finalPlacement;

            if (options.smartPlacement) {
                priorityList = $.fn.powerTip.smartPlacementLists[options.placement];

                // iterate over the priority list and use the first placement option
                // that does not collide with the view port. if they all collide
                // then the last placement in the list will be used.
                $.each(priorityList, function(idx, pos) {
                    // place tooltip and find collisions
                    var collisions = getViewportCollisions(
                        placeTooltip(element, pos),
                        tipElement.outerWidth(),
                        tipElement.outerHeight()
                    );

                    // update the final placement variable
                    finalPlacement = pos;

                    // break if there were no collisions
                    if (collisions === Collision.none) {
                        return false;
                    }
                });
            } else {
                // if we're not going to use the smart placement feature then just
                // compute the coordinates and do it
                placeTooltip(element, options.placement);
                finalPlacement = options.placement;
            }

            // add placement as class for CSS arrows
            tipElement.addClass(finalPlacement);
        }

        /**
         * Sets the tooltip position to the appropriate values to show the tip at
         * the specified placement. This function will iterate and test the tooltip
         * to support elastic tooltips.
         * @private
         * @param {jQuery} element The element that the tooltip should target.
         * @param {string} placement The placement for the tooltip.
         * @return {CSSCoordinates} A CSSCoordinates object with the top, left, and
         *     right position values.
         */
        function placeTooltip(element, placement) {
            var iterationCount = 0,
                tipWidth,
                tipHeight,
                coords = new CSSCoordinates();

            // set the tip to 0,0 to get the full expanded width
            coords.set('top', 0);
            coords.set('left', 0);
            tipElement.css(coords);

            // to support elastic tooltips we need to check for a change in the
            // rendered dimensions after the tooltip has been positioned
            do {
                // grab the current tip dimensions
                tipWidth = tipElement.outerWidth();
                tipHeight = tipElement.outerHeight();

                // get placement coordinates
                coords = placementCalculator.compute(
                    element,
                    placement,
                    tipWidth,
                    tipHeight,
                    options.offset
                );

                // place the tooltip
                tipElement.css(coords);
            } while (
                // sanity check: limit to 5 iterations, and...
                ++iterationCount <= 5 &&
                // try again if the dimensions changed after placement
                (tipWidth !== tipElement.outerWidth() || tipHeight !== tipElement.outerHeight())
            );

            return coords;
        }

        /**
         * Checks for a tooltip desync and closes the tooltip if one occurs.
         * @private
         */
        function closeDesyncedTip() {
            var isDesynced = false;
            // It is possible for the mouse cursor to leave an element without
            // firing the mouseleave or blur event. This most commonly happens when
            // the element is disabled under mouse cursor. If this happens it will
            // result in a desynced tooltip because the tooltip was never asked to
            // close. So we should periodically check for a desync situation and
            // close the tip if such a situation arises.
            if (session.isTipOpen && !session.isClosing && !session.delayInProgress) {
                // user moused onto another tip or active hover is disabled
                if (session.activeHover.data(DATA_HASACTIVEHOVER) === false || session.activeHover.is(':disabled')) {
                    isDesynced = true;
                } else {
                    // hanging tip - have to test if mouse position is not over the
                    // active hover and not over a tooltip set to let the user
                    // interact with it.
                    // for keyboard navigation: this only counts if the element does
                    // not have focus.
                    // for tooltips opened via the api: we need to check if it has
                    // the forcedOpen flag.
                    if (!isMouseOver(session.activeHover) && !session.activeHover.is(':focus') && !session.activeHover.data(DATA_FORCEDOPEN)) {
                        if (tipElement.data(DATA_MOUSEONTOTIP)) {
                            if (!isMouseOver(tipElement)) {
                                isDesynced = true;
                            }
                        } else {
                            isDesynced = true;
                        }
                    }
                }

                if (isDesynced) {
                    // close the desynced tip
                    hideTip(session.activeHover);
                }
            }
        }

        // expose methods
        this.showTip = beginShowTip;
        this.hideTip = hideTip;
        this.resetPosition = positionTipOnElement;
    }

    /**
     * Determine whether a jQuery object is an SVG element
     * @private
     * @param {jQuery} element The element to check
     * @return {boolean} Whether this is an SVG element
     */
    function isSvgElement(element) {
        return window.SVGElement && element[0] instanceof SVGElement;
    }

    /**
     * Initializes the viewport dimension cache and hooks up the mouse position
     * tracking and viewport dimension tracking events.
     * Prevents attaching the events more than once.
     * @private
     */
    function initTracking() {
        if (!session.mouseTrackingActive) {
            session.mouseTrackingActive = true;

            // grab the current viewport dimensions on load
            $(function getViewportDimensions() {
                session.scrollLeft = $window.scrollLeft();
                session.scrollTop = $window.scrollTop();
                session.windowWidth = $window.width();
                session.windowHeight = $window.height();
            });

            // hook mouse move tracking
            $document.on('mousemove', trackMouse);

            // hook viewport dimensions tracking
            $window.on({
                resize: function trackResize() {
                    session.windowWidth = $window.width();
                    session.windowHeight = $window.height();
                },
                scroll: function trackScroll() {
                    var x = $window.scrollLeft(),
                        y = $window.scrollTop();
                    if (x !== session.scrollLeft) {
                        session.currentX += x - session.scrollLeft;
                        session.scrollLeft = x;
                    }
                    if (y !== session.scrollTop) {
                        session.currentY += y - session.scrollTop;
                        session.scrollTop = y;
                    }
                }
            });
        }
    }

    /**
     * Saves the current mouse coordinates to the session object.
     * @private
     * @param {jQuery.Event} event The mousemove event for the document.
     */
    function trackMouse(event) {
        session.currentX = event.pageX;
        session.currentY = event.pageY;
    }

    /**
     * Tests if the mouse is currently over the specified element.
     * @private
     * @param {jQuery} element The element to check for hover.
     * @return {boolean}
     */
    function isMouseOver(element) {
        // use getBoundingClientRect() because jQuery's width() and height()
        // methods do not work with SVG elements
        // compute width/height because those properties do not exist on the object
        // returned by getBoundingClientRect() in older versions of IE
        var elementPosition = element.offset(),
            elementBox = element[0].getBoundingClientRect(),
            elementWidth = elementBox.right - elementBox.left,
            elementHeight = elementBox.bottom - elementBox.top;

        return session.currentX >= elementPosition.left &&
            session.currentX <= elementPosition.left + elementWidth &&
            session.currentY >= elementPosition.top &&
            session.currentY <= elementPosition.top + elementHeight;
    }

    /**
     * Fetches the tooltip content from the specified element's data attributes.
     * @private
     * @param {jQuery} element The element to get the tooltip content for.
     * @return {(string|jQuery|undefined)} The text/HTML string, jQuery object, or
     *     undefined if there was no tooltip content for the element.
     */
    function getTooltipContent(element) {
        var tipText = element.data(DATA_POWERTIP),
            tipObject = element.data(DATA_POWERTIPJQ),
            tipTarget = element.data(DATA_POWERTIPTARGET),
            targetElement,
            content;

        if (tipText) {
            if ($.isFunction(tipText)) {
                tipText = tipText.call(element[0]);
            }
            content = tipText;
        } else if (tipObject) {
            if ($.isFunction(tipObject)) {
                tipObject = tipObject.call(element[0]);
            }
            if (tipObject.length > 0) {
                content = tipObject.clone(true, true);
            }
        } else if (tipTarget) {
            targetElement = $('#' + tipTarget);
            if (targetElement.length > 0) {
                content = targetElement.html();
            }
        }

        return content;
    }

    /**
     * Finds any viewport collisions that an element (the tooltip) would have if it
     * were absolutely positioned at the specified coordinates.
     * @private
     * @param {CSSCoordinates} coords Coordinates for the element.
     * @param {number} elementWidth Width of the element in pixels.
     * @param {number} elementHeight Height of the element in pixels.
     * @return {number} Value with the collision flags.
     */
    function getViewportCollisions(coords, elementWidth, elementHeight) {
        var viewportTop = session.scrollTop,
            viewportLeft =  session.scrollLeft,
            viewportBottom = viewportTop + session.windowHeight,
            viewportRight = viewportLeft + session.windowWidth,
            collisions = Collision.none;

        if (coords.top < viewportTop || Math.abs(coords.bottom - session.windowHeight) - elementHeight < viewportTop) {
            collisions |= Collision.top;
        }
        if (coords.top + elementHeight > viewportBottom || Math.abs(coords.bottom - session.windowHeight) > viewportBottom) {
            collisions |= Collision.bottom;
        }
        if (coords.left < viewportLeft || coords.right + elementWidth > viewportRight) {
            collisions |= Collision.left;
        }
        if (coords.left + elementWidth > viewportRight || coords.right < viewportLeft) {
            collisions |= Collision.right;
        }

        return collisions;
    }

    /**
     * Counts the number of bits set on a flags value.
     * @param {number} value The flags value.
     * @return {number} The number of bits that have been set.
     */
    function countFlags(value) {
        var count = 0;
        while (value) {
            value &= value - 1;
            count++;
        }
        return count;
    }

}));