Source: bubble.js

/**
 * A JQuery widget for showing context-sensitive information for any form element in a popup bubble (aka tooltip).
 * 
 * Bubbles can be attached to any element and are automatically positioned to account for target element position 
 * on the page and page scroll such that they will always be visible on the page.  Bubbles will be positioned 
 * above/right, above/left, bottom/left, or bottom/right of the target using callout arrows to point to the target.
 * Each bubble contains a title and content, where content can be any HTML markup.  Hover over an element and the 
 * bubble appears immediately or after a configureable delay.  Move off the target or bubble and the bubble disappears 
 * immediately or after a configureable delay.
 * 
 * Bubble markup containing title and content can be sent with the page or created automatically in the browser.
 * Title and content can be editted directly inside the bubble via the "editable" option.
 *
 * @name bubble
 * @function
 * @namespace bubble
 * @property {Object} props Key-Value pairs of properties.
 * @property {string} props.title The bubble title
 * @property {string} props.content The bubble content, can include HTML markup.
 * @property {boolean} props.editable When set to true, the Edit icon is available for displaying the bubble
 *                                    in edit mode so title and content can be changed.
 * @property {string} props.ajax  URL to retrieve title and content in JSON format. This would be used
 *                                instead of the title and content attributes.
 */
(function ($) {

    /**
     * The custom event triggered when title or content of an editable bubble has changed.
     * @constant {string}
     * @event bubblechanged
     * @memberof bubble
     * @property {object} target The target element the bubble is associated with
     * @property {string} title The updated bubble title
     * @property {string} content The updated bubble content
     * @public
     */
    var EVENT_BUBBLE_CHANGE = "bubblechanged";

    // We may be dealing with 2 unique bubble instances at any time.
    // A pending bubble is one that is on a timer delay before it
    // becomes visible.  An active bubble is one that is visible.
    // These plugin variables represent these 2 Bubble class instances.
    //
    var pendingBubble = null;
    var activeBubble = null;

    // In general, a pending bubble can always be cancelled.  But when the pending bubble
    // is the result of an immediate bubble reactivation, then we want to block events that
    // would otherwise cause the bubble to be cancelled.
    var isCancelBlocked = false;

    var options;

    /**
     * Maximum percentage of the width of the bubble header that
     * the title can consume.  This should be a themeable parameter
     * that matches the default width specified in the stylesheet for "BubbleTitle".
     * @constant {number}
     * @default 75
     * @memberof bubble
     * @private
     */
    var BUBBLE_TITLE_WIDTH = 75;

    /**
     * The html template for the bubble widget.
     * @constant {string}
     * @memberof bubble
     * @private
     * @default
     */
    var TEMPLATE = '\
<div class="BubbleDiv" style="display:none">\n\
  <div class="BubbleShadow">\n\
    <div class="Bubble">\n\
      <div class="BubbleHeader">\n\
        <div class="BubbleTitle"></div>\n\
        <div class="BubbleCloseBtn"></div>\n\
        <div class="BubbleEditBtn"></div>\n\
      </div>\n\
      <div class="BubbleContent">\n\
      </div>\n\
      <div class="bottomLeftArrow"></div>\n\
      <div class="bottomRightArrow"></div>\n\
      <div class="topLeftArrow"></div>\n\
      <div class="topRightArrow"></div>\n\
    </div>\n\
  </div>\n\
</div>\n\
';

    // Plugin
    $.fn.bubble = function(opts) {

        options = $.extend({
            openDelay: 500,
            closeDelay: 2000,
            editable: "false"
        }, opts);

        // Listen for clicks on the document and check to see if the event target
        // is .BubbleDiv or has .BubbleDiv as a parent.  If it is not, then the click
        // originated from outside the bubble and we can cancel the bubble.
        $(document).click(function(event) {
            if (!$(event.target).closest('.BubbleDiv').length) {
                cancelBubble(event);
            }
        });

        return this.each(function() {
            var bubbleID = $(this).data("bubbleid");
            if (bubbleID == null) {
                // No bubbleID attached to this element, then create the bubble markup for it
                // with a unique ID and set the value of the data attribute of the element to
                // the bubble's unique ID.
                bubbleID = $(TEMPLATE).appendTo('body').uniqueId().attr("id");

                if (options.ajax != null) {
                    // Get bubble title and content via ajax.  Return should be a single
                    // json object with "title" and "content" options.
                    $.getJSON(options.ajax, function(data) {
                        // Success
                        $('#' + bubbleID + ' .BubbleTitle').html(data.title);
                        $('#' + bubbleID + ' .BubbleContent').html(data.content);
                    })
                    .fail(function(jqxhr, textStatus, error) {
                        // Put the error in the bubble.
                        var err = "Request failed: " + textStatus + " " + error;
                        $('#' + bubbleID + ' .BubbleTitle').html(options.ajax);
                        $('#' + bubbleID + ' .BubbleContent').html(err);
                        console.log(err );
                    });
                } else {
                    // Get bubble title and content from options.
                    $('#' + bubbleID + ' .BubbleTitle').html(options.title);
                    $('#' + bubbleID + ' .BubbleContent').html(options.content);
                }
                $(this).data("bubbleid", bubbleID);

                // Clear options that are specific per bubble instance.
                options.title = null;
                options.content = null;
                options.ajax = null;
            }

            // Add options for this bubble.
            $('#' + bubbleID).data("editable", options.editable);

            $('#' + bubbleID + ' .BubbleCloseBtn').click(function(event) {
                cancelBubble(event);
            });

            $('#' + bubbleID + ' .BubbleEditBtn').click(function(event) {
                var contentSelector = '#' + bubbleID + ' .BubbleContent';
                var contentEditorSelector = '#' + bubbleID + ' .BubbleContentEditor';
                var contentTextarea = $(contentEditorSelector + ' textarea');
                var titleSelector = '#' + bubbleID + ' .BubbleTitle';
                var titleEditorSelector = '#' + bubbleID + ' .BubbleTitleEditor';
                var titleTextarea = $(titleEditorSelector + ' textarea');

                // Convert content and title html to editable text, removing embedded newlines and replacing <br> tags with newlines.
                var content = $(contentSelector).html()
                        .split("\n").join("")
                        .split("<br/>").join("\n")
                        .split("<br>").join("\n");
                var title = $(titleSelector).html()
                        .split("\n").join("")
                        .split("<br/>").join("\n")
                        .split("<br>").join("\n");

                if (contentTextarea.length == 0) {
                    // Move the bubble content into an editable textarea inside its own parent div.
                    $("<div></div>").addClass("BubbleContentEditor").insertAfter(contentSelector);
                    $("<textarea></textarea>").val($.trim(content)).appendTo(contentEditorSelector)
                        .height($(contentSelector).height() + 20);

                    // Move the bubble title into an editable textarea inside its own parent div.
                    $("<div></div>").addClass("BubbleTitleEditor").insertAfter(titleSelector);
                    $("<textarea></textarea>").val($.trim(title)).appendTo(titleEditorSelector)
                        .width($(titleSelector).width())
                        .height($(titleSelector).height());

                    $(contentSelector).css({"display": "none"});
                    $(titleSelector).css({"display": "none"});
                    setArrowPosition(bubbleID);
                } else {
                    // Move the editted content from the textarea to bubble html content, replacing newlines
                    // with <br> tags, and remove the editor.
                    var newContent = contentTextarea.val()
                        .trim()
                        .split("\n").join("<br/>");
                    $(contentSelector).html(newContent);
                    $(contentEditorSelector).remove();
                    $(contentSelector).css({"display": "block"});

                    // Move the editted title from the textarea to bubble html content, replacing newlines
                    // with <br> tags, and remove the editor.
                    var newTitle = titleTextarea.val()
                        .trim()
                        .split("\n").join("<br/>");
                    $(titleSelector).html(newTitle);
                    $(titleEditorSelector).remove();
                    $(titleSelector).css({"display": "block"});

                    // Immediately reactivate the bubble with the updated content.
                    // We do this by triggering a click event on the document to cancel the bubble,
                    // Never trigger a click on the target as that could be handled by the target.
                    // followed by a mouseenter on that target to initialize it again.
                    // Note the customization to override the delay in displaying the bubble.
                    var bubble = $("#" + bubbleID);
                    var payload = bubble.data("payload");
                    var e = jQuery.Event("click");
                    $(document).trigger(e);
                    isCancelBlocked = true;
                    e = jQuery.Event("mouseenter");
                    e.openDelay = 0;
                    $(payload.target).trigger(e);

                    // Fire event to notify the associated target that it's bubble title and/or content has changed.
                    $(payload.target).trigger(EVENT_BUBBLE_CHANGE, [payload.target, newTitle, newContent]);
                }
            });

            $(this)
                .addClass("ui-bubbleable")
                .mouseenter(function(event) {
                    var bubbleID = $(this).data("bubbleid");
                    if (bubbleID != null) {
                        initBubble(bubbleID, event);
                    }})
                .mouseout(function(event) {
                    cancelBubble(event);})
                .mousedown(function(event) {
                    // Listen for clicks on the target and check to see if the event target
                    // is .BubbleDiv or has .BubbleDiv as a parent.  If it is not, then the click
                    // originated from outside the bubble and we can cancel the bubble.
                    if (!$(event.target).closest('.BubbleDiv').length) {
                        cancelBubble(event);
                    }});

            return $(this);
        });

    };


    /****** Bubble class ******/

    /**
     * Backing class for pending and active bubbles.
     *
     * @namespace Bubble
     * @function Bubble
     * @class
     * @constructor
     * @param id       ID of the bubble to start
     * @param evt      event associated with the creation of this bubble.
     * @private
     */
    function Bubble(id, evt) {
        this.id = id;
        this.evt = evt;
        this.target = evt.target;
        this.timeoutID = null;
        this.cancelled = false;  // only applicable to an active bubble
        this.stopped = false;    // only applicable to an active bubble
        this.arrow = null;
    
        // Get the absolute position of the target.
        var offset = $(this.target).offset();
        this.target.absLeft = offset.left;
        this.target.absTop = offset.top;
    
        // Get the JQuery object for the bubble.
        var bubble = $("#" + this.id);
    
        // The 1st instance of this bubble element will not have the "payload" property
        // set.  In this case, we want to grab any positioning information that may have
        // been specified as part of the style attribute.  Specific positioning can be
        // used to override the default positioning of the bubble.
        //
        var payload = bubble.data("payload");
        if (payload == null) {
            var t = parseInt($("#" + this.id).css("top"));
            var l = parseInt($("#" + this.id).css("left"));
            if ($.isNumeric(t)) {
                this.top = t;
            }
            if ($.isNumeric(l)) {
                this.left = l;
            }
        } else {
            // Old payload exists, so migrate positioning info from it to this object.
            this.top = payload.top;
            this.left = payload.left;
        }
    
        // Attach our Bubble object to the JQuery object as the payload data.
        bubble.data("payload", this);
    
        bubble.mouseover(function(event) {
            // If bubble scheduled to be stopped, cancel the scheduled stop.
            var payload = $(this).data("payload");
            if (payload.timeoutID != null)
                clearTimeout(payload.timeoutID);
            payload.cancelled = false;
            payload.timeoutID = null;
        });

        bubble.mouseout(function(event) {
            // Treat same a mouseout on target.
            var payload = $(this).data("payload");
            var e = jQuery.Event("mouseout");
            $(payload.target).trigger(e);
        });

        // Display the Edit button icon if bubble is edittable.
        var editable = $('#' + id).data("editable")
        if (editable == true) {
            $("#" + id + ".BubbleDiv .BubbleEditBtn").css({"display": "block"});
        } else {
            $("#" + id + ".BubbleDiv .BubbleEditBtn").css({"display": "none"});
        }

        // Initialize the BubbleTitle width as a percentage of the bubble header.
        $("#" + bubble.id + ".BubbleDiv .BubbleTitle").width(BUBBLE_TITLE_WIDTH + "%");
    };

    /**
     * Start bubble
     *
     * @function
     * @memberof Bubble
     * @this Bubble
     * @instance
     * @private
     */
    function start() {
    
        // Get JQuery bubble object associated with this Bubble instance.
        var bubble = $("#" + this.id);
        if (bubble.length == 0) {
            return;
        }
    
        // If bubble already rendered, do nothing.
        if (bubble.css("display") == "block") {
            return;
        }
    
        // Render the bubble.  Must do this here, else target properties referenced
        // below will not be valid.
        bubble.css("display", "block");
    
    
        ////////////////////////////////////////////////////////////////////
    
        // THIS CODE BLOCK IS NECESSARY WHEN THE PAGE FONT IS VERY SMALL,
        // AND WHICH OTHERWISE CAUSES THE PERCENTAGE OF THE HEADER WIDTH
        // ALLOCATED TO THE BUBBLE TITLE TO BE TOO LARGE SUCH THAT IT
        // ENCROACHES ON THE SPACE ALLOCATED FOR THE CLOSE BUTTON ICON,
        // RESULTING IN LAYOUT MISALIGNMENT IN THE HEADER.
    
        // Assume BubbleTitle width max percentage of the bubble header.
        var maxPercent = BUBBLE_TITLE_WIDTH;
    
        // Sum of widths of all elements in the header BUT the title.  This includes
        // the width of the close button icon, and the margins around the button and
        // the title.  This should be a themeable parameter that matches the left/right
        // margins specified in the stylesheet for "BubbleTitle" and "BubbleCloseBtn".
        nonTitleWidth = 39;
    
        // Get the widths (in pixels) of the bubble header and title
        var headerWidth = $("#" + bubble.attr("id") + ".BubbleDiv .BubbleHeader").width();
        var titleWidth = $("#" + bubble.attr("id") + ".BubbleDiv .BubbleTitle").width();
    
        // Revise the aforementioned percentage downward until the title no longer
        // encroaches on the space allocated for the close button.  We decrement by
        // 5% each time because by doing so in smaller chunks when the font gets so
        // small only results in unnecessary extra loop interations.
        //
        if (headerWidth > nonTitleWidth) {
            while ((maxPercent > 5) && (titleWidth > (headerWidth - nonTitleWidth))) {
                maxPercent -= 5;
                $("#" + bubble.attr("id") + ".BubbleDiv .BubbleTitle").width(maxPercent + "%");
                titleWidth = $("#" + bubble.attr("id") + ".BubbleDiv .BubbleTitle").width();
            }
        }
    
        ////////////////////////////////////////////////////////////////////
    
    
        // If specific positioning specified, then simply use it.  This means the bubble
        // will not contain any callout arrows and no provisions are made to guarantee
        // the bubble renders in the viewable area.
        if ((this.top != null) && (this.left != null)) {
    
            bubble.css({"left": this.left, "top": this.top});
    
        } else {
    
            // No positioning specified, so we calculate the optimal position to guarantee
            // bubble is fully viewable and includes callout arrows.
    
    
            // A bubble can render one of 4 callout arrow images, each of which are 
            // child nodes of the bubble.  To get access to those nodes, we have to
            // traverse the bubble's container hierarchy.
            //
            var bottomLeftArrow = $("#" + bubble.attr("id") + ".BubbleDiv .bottomLeftArrow");
            var bottomRightArrow = $("#" + bubble.attr("id") + ".BubbleDiv .bottomRightArrow");
            var topLeftArrow = $("#" + bubble.attr("id") + ".BubbleDiv .topLeftArrow");
            var topRightArrow = $("#" + bubble.attr("id") + ".BubbleDiv .topRightArrow");
    
            var slidLeft = false;
    
            // Assume default bubble position northeast of target, which implies a 
            // bottomLeft callout arrow
            this.arrow = bottomLeftArrow;
    
    	    // Get the <html> node so can get scroll info
            var html = $("html");
    
            // Try to position bubble to right of target.
            var bubbleLeft = this.target.absLeft + this.target.offsetWidth + 5;
    
            // Check if right edge of bubble exceeds page boundary.
            var rightEdge = bubbleLeft + bubble.width();
            if (rightEdge > ($(document).width() + html.scrollLeft())) {
    
                // Shift bubble to left side of target;  implies a bottomRight arrow.
                bubbleLeft = this.target.absLeft - bubble.width();
                this.arrow = bottomRightArrow;
                slidLeft = true;
    
                // If left edge of bubble crosses left page boundary, then
                // reposition bubble back to right of target and implies to go
                // back to bottomLeft arrow.  User will need to use scrollbars
                // to position bubble into view.
                if (bubbleLeft <= 0) {
                    bubbleLeft = this.target.absLeft + this.target.offsetWidth + 5;
                    this.arrow = bottomLeftArrow;
                    slidLeft = false;
                }
            }
    
            // Try to position bubble above target
            var bubbleTop = this.target.absTop - bubble.height();
    
            // Check if top edge of bubble crosses top page boundary
            if (bubbleTop <= html.scrollTop()) {
                // Shift bubble to bottom of target.  User may need to use scrollbars
                // to position bubble into view.
                bubbleTop = this.target.absTop + this.target.offsetHeight + 5;
    
                // Use appropriate top arrow depending on left/right position.
                if (slidLeft == true)
                    this.arrow = topRightArrow;
                else
                    this.arrow = topLeftArrow;
            }
    
            // Set new bubble position.
            bubble.css({"left": bubbleLeft + "px", "top": bubbleTop + "px"});
    
            // If rendering a callout arrow, set it's position relative to the bubble.
            if (this.arrow != null) {
                $(this.arrow).css({"display": "block", "visibility": "visible"});
                setArrowPosition(this.id);
            }
        }
    
        this.evt = null;
        this.cancelled = false;
        this.stopped = false;
        this.timeoutID = null;
    
    } // start


    /**
     * Stop the bubble.  If an event is provided, the stop
     * may be conditional on the event type.  If no event
     * is provided, then use the event member for this class,
     * it may have been posted there.  Otherwise if no
     * event is available, then force an unconditional stop.
     *
     * @function
     * @memberof Bubble
     * @this Bubble
     * @instance
     * @private
     * @param evt  event associated with stopping the bubble.
     */
    function stop(evt) {
    
        // Clear any timeout associated with this bubble.
        if (this.timeoutID != null) {
            clearTimeout(this.timeoutID);
            this.timeoutID = null;
        }
        this.cancelled = false;
    
        // Get bubble object.
        var bubble = $("#" + this.id);
        if (bubble.length == 0) {
            return;
        }
    
        // If bubble not already rendered, do nothing.
        if (bubble.css("display") != "block") {
            return;
        }
    
        var evt = (evt) ? evt : this.evt;
        this.evt = null;
    
        // If the event source is any element contained in the bubble
        // BUT the close icon, then simply return without dismissing the bubble.
        if (evt != null) {
            var target = evt.target;
            while (target != null) {
                // Stop loop if bubble's close button clicked.
                if ((evt.type == "click") && (target.className != null) && (target.className == "BubbleCloseBtn"))
                    break;
    
                if (target.parentNode != null) {
                    if (target.parentNode.id == this.id)
                        // Event source is bubble, so ignore the event.
                        return;
                }
                target = target.parentNode;
            }
        }
    
        // Dismiss the bubble for all events outside the bubble.
        if (this.arrow != null) {
            $(this.arrow).css({"display": "none", "visibility": "hidden"});
        }
        bubble.css("display", "none");
        this.stopped = true;
    
    } // stop

    // Interfaces for Bubble class
    // We make the functions instance methods by assigning them
    // to the prototype object of the constructor.
    //
    Bubble.prototype.start = start;
    Bubble.prototype.stop = stop;

    /****** End Bubble class ******/

    /**
     * Initialize a bubble to start after a specified period of time.
     *
     * @function
     * @memberof bubble
     * @static
     * @private
     * @param id       ID of the bubble to start
     * @param evt      event that triggered this handler
     *                 The Event may be extended with the 'openDelay' attribute,
     *                 the number of milliseconds to delay start of the bubble.
     *                 If not specified, the delay is as specified in the plugin options
     */
    function initBubble(id, evt, delay) {

        // Do not initialize a bubble that is already pending.
        if ((pendingBubble != null) && (pendingBubble.id == id)) {
            return;
        }
    
        // Do not initialize a bubble that is already active.
        // If it was cancelled, we clear the cancel and leave
        // the bubble active.
        if ((activeBubble != null) && (activeBubble.id == id)) {
            if (activeBubble.cancelled == true) {
                clearTimeout(activeBubble.timeoutID);
                activeBubble.cancelled = false;
                activeBubble.timeoutID = null;
            }
            return;
        }
    
        pendingBubble = new Bubble(id, evt);
        pendingBubble.timeoutID = setTimeout(startBubble, (evt.openDelay != null) ? evt.openDelay : options.openDelay);
    
    } // initBubble
    
    
    /**
     * Cancel the bubble.  It is possible we could be in a state of
     * transitioning between an active and pending bubble.  So we
     * unconditionally stop the pending bubble.  The active bubble
     * is "softly" cancelled only upon a mouseout event, otherwise
     * it too is stopped unconditionally.
     *
     * @function
     * @memberof bubble
     * @static
     * @private
     * @param evt      event that triggered this handler
     */
    function cancelBubble(evt) {

        if (isCancelBlocked == true) {
            return;
        }
    
        // Unconditionally stop pending bubble.
        if (pendingBubble != null) {
            pendingBubble.stop();
            pendingBubble = null;
        }
    
        // Delay stop of active bubble on mouseout event, and only if
        // if it has not already been scheduled for stoppage.  Otherwise,
        // unconditionally stop it.
        //
        if (activeBubble != null) {
            if (evt.type == "mouseout") {
                if ((evt.target.id == activeBubble.target.id) && (activeBubble.cancelled == false)) {
                    activeBubble.evt = evt;
                    activeBubble.timeoutID = setTimeout(stopBubble, options.closeDelay);
                    activeBubble.cancelled = true;
                }
            } else {
                // If in edit mode, remove the editors and restore the content and title.
                var contentSelector = '#' + activeBubble.id + ' .BubbleContent';
                var contentEditorSelector = '#' + activeBubble.id + ' .BubbleContentEditor';
                var contentTextarea = $(contentEditorSelector + ' textarea');
                var titleSelector = '#' + activeBubble.id + ' .BubbleTitle';
                var titleEditorSelector = '#' + activeBubble.id + ' .BubbleTitleEditor';
                var titleTextarea = $(titleEditorSelector + ' textarea');
                if (contentTextarea.length != 0) {
                    $(contentEditorSelector).remove();
                    $(contentSelector).css({"display": "block"});
                    $(titleEditorSelector).remove();
                    $(titleSelector).css({"display": "block"});
                }

                stopBubble(evt);
            }
        }
    
    } // cancelBubble
    
    
    /**
     * If a new bubble is pending, start it.  If another bubble already
     * started, stop it.
     * @function
     * @memberof bubble
     * @static
     * @private
     */
    function startBubble() {
    
        // Stop existing bubble unconditionally, if it exists.
        if (activeBubble != null) {
            activeBubble.stop()
        }
        activeBubble = null;
    
        // If no pending bubble registered, simply return.
        if (pendingBubble == null) {
            return;
        }

        // Don't activate pending bubble for target that is dragging.
        if ($(pendingBubble.target).hasClass('ui-draggable-dragging')) {
            pendingBubble = null;
            return;
        }
    
        // Pending bubble becomes the active one.
        activeBubble = pendingBubble;
        pendingBubble = null;
        activeBubble.start();
        isCancelBlocked = false;
    
    } // startBubble
    
    
    /**
     * Stop the active bubble.
     * @function
     * @memberof bubble
     * @static
     * @private
     *
     * @param evt  event associated with stopping the bubble, or null.
     */
    function stopBubble(evt) {
    
        if (activeBubble == null)
            return;
            
        activeBubble.stop(evt);
        if (activeBubble.stopped == true)
            activeBubble = null;
    
    } // stopBubble

    /**
     * Set the positions of the callout arrow for the specified bubble.
     * @function
     * @memberof bubble
     * @static
     * @private
     *
     * @param bubbleID  the ID of the bubble
     */
    function setArrowPosition(bubbleID) {
        // Get a handle to the top arrow if it is displayed.
        var topLeftArrow = $("#" + bubbleID + ".BubbleDiv .topLeftArrow");
        var topRightArrow = $("#" + bubbleID + ".BubbleDiv .topRightArrow");
        var isTopLeftArrowVisible = (topLeftArrow.css("display") == "block") ? true : false;
        var isTopRightArrowVisible = (topRightArrow.css("display") == "block") ? true : false;;
        var topArrow = null;
        if (isTopLeftArrowVisible == true) { topArrow = topLeftArrow; }
        if (isTopRightArrowVisible == true) { topArrow = topRightArrow; }

        if (topArrow != null) {
            // Top position for top arrows is a relative vertical shift by an
            // amount almost equal to the bubble height, but with an adjustment.
            // For some reason, IE8 and Opera require custom positioning of the top arrows.
            // Don't know which "support" item to check for so we check the browser.
            var adjustment = -2;
            if (typeof(WebBrowser) != "undefined") {
                if ((WebBrowser.isIE() && (WebBrowser.getVersion() == 8)) 
                     || WebBrowser.isOpera()) {
                    adjustment = 3;
                }
            }
            var bubble = $("#" + bubbleID);
            topArrow.css("top",  -(bubble.height() + adjustment) + "px");
        }
    }

})(jQuery);