// // Copyright (c) 2008 Beau D. Scott | http://www.beauscott.com // // 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. // /** * HelpBalloon.js * Prototype/Scriptaculous based help balloons / dialog balloons * @version 2.0.1 * @requires prototype.js * @author Beau D. Scott * 4/10/2008 */ var HelpBalloon = Object.extend(Class.create(), { /** * Enumerated value for dynamic rendering position. * @static */ POS_DYNAMIC: -1, /** * Enumerated value for the top-left rendering position. * @static */ POS_TOP_LEFT: 0, /** * Enumerated value for the top-right rendering position. * @static */ POS_TOP_RIGHT: 1, /** * Enumerated value for the bottom-left rendering position. * @static */ POS_BOTTOM_LEFT: 2, /** * Enumerated value for the bottom-right rendering position. * @static */ POS_BOTTOM_RIGHT: 3, /** * CSS Classname to look for when doing auto link associations. * @static */ ELEMENT_CLASS_NAME: 'HelpBalloon', /** * Global array of all HelpBalloon instances. * (Cheaper than document.getElementByClassName with a property check) * @static * @private */ _balloons: [], /** * Event listener that auto-associates anchors with a dynamic HelpBalloon. * Also begins mouse movement registration * @static */ registerClassLinks: function(e) { $A(document.getElementsByClassName(HelpBalloon.ELEMENT_CLASS_NAME)) .each(function(obj){ // Only apply any element with an href tag if(obj && obj.tagName && obj.href && obj.href != '') { new HelpBalloon({ icon:obj, method: 'get' }); } }); Event.observe(document, 'mousemove', HelpBalloon._trackMousePosition); }, /** * Private cache of the client's mouseX position */ _mouseX: 0, /** * Private cache of the client's mouseY position */ _mouseY: 0, /** * @param {Event} e */ _trackMousePosition: function(e) { if(!e) e = window.event; HelpBalloon._mouseX = e.clientX; HelpBalloon._mouseY = e.clientY; } }); // // Event for activating HelpBalloon classed links // Event.observe(window, 'load', HelpBalloon.registerClassLinks); HelpBalloon.prototype = { // // Properties // /** * Configuration options * @var {HelpBalloon.Options} */ options: null, /** * Containing element of the balloon * @var {Element} */ container: null, /** * Inner content container * @var {Element} */ inner: null, /** * A reference to the anchoring element/icon * @var {Element} */ icon: null, /** * Content container * @var {Element} */ content: null, /** * Closing button element * @var {Element} */ button: null, /** * The closer object. This can be the same as button, but could * also be a div with a png loaded as the back ground, browser dependent. * @var {Element} */ closer: null, /** * Title container * @var {Element} */ titleContainer: null, /** * Background container (houses the balloon images * @var {Element} */ bgContainer: null, /** * Array of balloon image references * @var {Array} */ balloons: null, /** * The local store of 'title'. Will change if the balloon is making a remote call * unless options.title is specified * @var {String} */ _titleString: null, /** * The balloons visibility state. * @var {Boolean} */ visible: false, /** * Rendering status * @var {Boolean} */ drawn: false, /** * Stores the balloon coordinates * @var {Object} */ balloonCoords: null, /** * Width,height of the balloons * @var {Array} */ balloonDimensions: null, /** * ID for HelpBalloon * @var {String} */ id: null, /** * Used at render time to measure the dimensions of the loaded balloon * @private */ _lastBalloon: null, // // Methods // /** * @param {Object} options * @see HelpBalloon.Options * @constructor */ initialize: function(options) { this.options = new HelpBalloon.Options(); Object.extend(this.options, options || {}); this._titleString = this.options.title; this.balloonDimensions = [0,0]; // // Preload the balloon and button images so they're ready // at render time // // 0 1 // X // 2 3 // this.balloons = []; for(var i = 0; i < 4; i++) { var balloon = new Element('img', { src: this.options.balloonPrefix + i + this.options.balloonSuffix }); this.balloons.push(balloon.src); } this._lastBalloon = balloon; this.button = new Element('img', { src: this.options.button }); // // Create the anchoring icon, or attach the balloon to the given icon element // If a string is passed in, assume it's a URL, if it's an object, assume it's // a DOM member. // if(typeof this.options.icon == 'string') { this.icon = new Element('img', { src: this.options.icon, id: this.id + "_icon" }); Element.setStyle(this.icon, this.options.iconStyle); } else { // // Not a string given (most likely an object. Do not append the element // Kind of a hack for now, but I'll fix it in the next version. // this.icon = this.options.icon; this.options.returnElement = true; } this.icon._HelpBalloon = this; // // Attach rendering events // for(i = 0; i < this.options.useEvent.length; i++) Event.observe(this.icon, this.options.useEvent[i], this.toggle.bindAsEventListener(this)); this.container = new Element('div'); this.container._HelpBalloon = this; this.id = 'HelpBalloon_' + Element.identify(this.container); HelpBalloon._balloons.push(this); // // If we are not relying on other javascript to attach the anchoring icon // to the DOM, we'll just do where the script is called from. Default behavior. // // If you want to use external JavaScript to attach it to the DOM, attach this.icon // if(!this.options.returnElement) { document.write(''); var te = $(this.id); var p = te.parentNode; p.insertBefore(this.icon, te); p.removeChild(te); } }, /** * Toggles the help balloon * @param {Object} e Event */ toggle: function(event) { if(!event) event = window.event || {type: this.options.useEvent, target: this.icon}; var icon = Event.element(event); Event.stop(event); if(event.type == this.options.useEvent && !this.visible && icon == this.icon) { this.show(event); } else this.hide(); }, /** * Triggers the balloon to appear */ show: function(event) { if(!this.visible){ if(!event) event = window.event; if(!this.drawn || !this.options.cacheRemoteContent) this._draw(); this._reposition(event); this._hideOtherHelps(); if(this.options.showEffect) { this.options.showEffect(this.container, Object.extend(this.options.showEffectOptions, { afterFinish: this._afterShow.bindAsEventListener(this) })); } else { this._afterShow(); } Event.observe(window, 'resize', this._reposition.bindAsEventListener(this)); } }, /** * Sets the container to block styling and hides the elements below the * container (if in IE) * @private */ _afterShow: function() { Element.setStyle(this.container, { 'display': 'block' }); this._hideLowerElements(); this.visible = true; if(this.options.autoHideTimeout) { setTimeout(this._hideQueue.bind(this), this.options.autoHideTimeout); } }, /** * Checks the mouse position and triggers a hide after the time specified in autoHideTimeout * if the mouse is not currently over the balloon, otherwise it requeue's a hide for later. */ _hideQueue: function() { if(Position.within(this.container, HelpBalloon._mouseX, HelpBalloon._mouseY)) setTimeout(this._hideQueue.bind(this), this.options.autoHideTimeout); else this.hide(); }, /** * Hides the balloon */ hide: function() { if(this.visible) { this._showLowerElements(); if(this.options.hideEffect) { this.options.hideEffect(this.container, Object.extend(this.options.hideEffectOptions, { afterFinish: this._afterHide.bindAsEventListener(this) })); } else { this._afterHide(); } Event.stopObserving(window, 'resize', this._reposition.bindAsEventListener(this)); } }, /** * Sets the container's display to block * @private */ _afterHide: function() { Element.setStyle(this.container, { 'display': 'none' }); this.visible = false; }, /** * Redraws the balloon based on the current coordinates of the icon. * @private */ _reposition: function(event) { if(this.icon.tagName.toLowerCase() == 'area' || !!this.icon.isMap) { this.balloonCoords = Event.pointer(event); } else { this.balloonCoords = this._getXY(this.icon); //Horizontal and vertical offsets in relation to the icon's 0,0 position. // Default is the middle of the object var ho = this.icon.offsetWidth / 2; var vo = this.icon.offsetHeight / 2; var offsets = this.options.anchorPosition.split(/\s+/gi); // Only use the first two specified values if(offsets.length > 2) offsets.length = 2; for(var i = 0; i < offsets.length; i++) { switch(offsets[i].toLowerCase()) { case 'left': ho = 0; break; case 'right': ho = this.icon.offsetWidth; break; case 'center': ho = this.icon.offsetWidth / 2; break; case 'top': vo = 0; break; case 'middle': vo = this.icon.offsetHeight / 2; break; case 'bottom': vo = this.icon.offsetHeight; break; default: var numVal = parseInt(offsets[i]); if(!isNaN(numVal)) { // 0 = width, 1 = height (WxH) if(i == 0) { if(numVal < 0) { ho = 0; } else { if(numVal > this.icon.offsetWidth) ho = this.icon.offsetWidth; else ho = numVal } } else { if(numVal < 0) { vo = 0; } else { if(numVal > this.icon.offsetHeight) vo = this.icon.offsetHeight; else vo = numVal } } } break; } } this.balloonCoords.x += ho; this.balloonCoords.y += vo; } // // Figure out what position to show based on available realestate // unless // 0 1 // X // 2 3 // Number indicates position of corner opposite anchor // var pos = 1; if(this.options.fixedPosition == HelpBalloon.POS_DYNAMIC) { var offsetHeight = this.balloonCoords.y - this.balloonDimensions[1]; if(offsetHeight < 0) pos += 2; var offsetWidth = this.balloonCoords.x + this.balloonDimensions[0]; var ww = Prototype.Browser.IE ? document.body.clientWidth : window.outerWidth; if(offsetWidth > ww) pos -- ; } else pos = this.options.fixedPosition; var zx = 0; var zy = 0; // // 0 1 // X // 2 3 // switch(pos) { case 0: zx = this.balloonCoords.x - this.balloonDimensions[0]; zy = this.balloonCoords.y - this.balloonDimensions[1]; break; case 1: zx = this.balloonCoords.x; zy = this.balloonCoords.y - this.balloonDimensions[1]; break; case 2: zx = this.balloonCoords.x - this.balloonDimensions[0]; zy = this.balloonCoords.y; break; case 3: zx = this.balloonCoords.x; zy = this.balloonCoords.y; break; } var containerStyle = { /*'backgroundRepeat': 'no-repeat', 'backgroundColor': 'transparent', 'backgroundPosition': 'top left',*/ 'left' : zx + "px", 'top' : zy + "px", 'width' : this.balloonDimensions[0] + 'px', 'height' : this.balloonDimensions[1] + 'px' } if(Prototype.Browser.IE) { // // Fix for IE alpha transparencies // if(this.balloons[pos].toLowerCase().indexOf('.png') > -1) { Element.setStyle(this.bgContainer, { 'left' : '0px', 'top' : '0px', 'filter' : "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='" + this.balloons[pos] + "', sizingMethod='scale')", 'width' : this.balloonDimensions[0] + 'px', 'height' : this.balloonDimensions[1] + 'px', 'position' : 'absolute' }); } else containerStyle['background'] = 'transparent url(' + this.balloons[pos] + ') top left no-repeat'; } else { containerStyle['background'] = 'transparent url(' + this.balloons[pos] + ') top left no-repeat'; } Element.setStyle(this.container, containerStyle); }, /** * Renders the Balloon * @private */ _draw: function() { Element.setStyle( this.container, Object.extend(this.options.balloonStyle, { 'position': 'absolute', 'display': 'none' }) ); var url = this.options.dataURL; // // Play nicely with anchor tags being used as the icon. Use it's specified href as our // data URL unless one has already been used specified in this.options.dataURL. // We'll also force a new request with this as it may be an image map. // if(this.icon.className == 'a') { if(!this.options.dataURL && this.icon.href != ''){ url = this.icon.href; this.options.cacheRemoteContent = false; } } if(url && (!this.drawn || !this.options.cacheRemoteContent)) { var cont = new Ajax.Request(this.options.dataURL, {asynchronous: false, method: this.options.method}); // // Expects the following XML format: // // My Title // My content // // var doHTML = false; if(cont.transport.responseXML) { var xml = cont.transport.responseXML.getElementsByTagName('HelpBalloon')[0]; if(xml) { if(!this.options.title) { xmlTitle = xml.getElementsByTagName('title')[0]; if(xmlTitle) this._titleString = xmlTitle.firstChild.nodeValue; } xmlContent = xml.getElementsByTagName('content')[0]; if(xmlContent) this.options.content = xmlContent.firstChild.nodeValue; } else doHTML = true; } else doHTML = true; if(doHTML) { // Attempt to get the title from a HTML tag, unless the title option has been set. If so, use that. if(!this.options.title) { var htmlTitle = cont.transport.responseText.match(/\<title\>([^\<]+)\<\/title\>/gi); if(htmlTitle) { htmlTitle = htmlTitle.toString().replace(/\<title\>|\<\/title\>/gi, ''); this._titleString = htmlTitle; } } this.options.content = cont.transport.responseText; } } this.balloonDimensions[0] = this._lastBalloon.width; this.balloonDimensions[1] = this._lastBalloon.height; var contentDimensions = [ this.balloonDimensions[0] - (2 * this.options.contentMargin), this.balloonDimensions[1] - (2 * this.options.contentMargin) ]; var buttonDimensions = [ this.button.width, this.button.height ]; // // Create all the elements on demand if they haven't been created yet // if(!this.drawn) { this.inner = new Element('div'); this.titleContainer = new Element('div'); this.inner.appendChild(this.titleContainer); // PNG fix for IE if(Prototype.Browser.IE && this.options.button.toLowerCase().indexOf('.png') > -1) { this.bgContainer = new Element('div'); // Have to create yet-another-child of container to house the background for IE... when it was set in // the main container, it for some odd reason prevents child components from being clickable. this.container.appendChild(this.bgContainer); this.closer = new Element('div'); Element.setStyle(this.closer, { 'filter': "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='" + this.options.button + "', sizingMethod='scale')" }); } else { this.closer = this.button; } Event.observe(this.closer, 'click', this.toggle.bindAsEventListener(this)); this.inner.appendChild(this.closer); this.content = new Element('div'); this.inner.appendChild(this.content); this.container.appendChild(this.inner); document.getElementsByTagName('body')[0].appendChild(this.container); this.drawn = true; } // Reset the title element and reappend the title value (could have changed with a new URL) this.titleContainer.innerHTML = ''; this.titleContainer.appendChild(document.createTextNode(this._titleString)); // Reset content value: this.content.innerHTML = this.options.content; // // Reapply styling to components as values might have changed // Element.setStyle(this.inner, { 'position': 'absolute', 'top': this.options.contentMargin + 'px', 'left': this.options.contentMargin + 'px', 'width': contentDimensions[0] + 'px', 'height': contentDimensions[1] + 'px' }); Element.setStyle(this.titleContainer, { 'width': (contentDimensions[0] - buttonDimensions[0]) + 'px', 'height': buttonDimensions[1] + 'px', 'position': 'absolute', 'overflow': 'hidden', 'top': '0px', 'left': '0px' }); Element.setStyle(this.titleContainer, this.options.titleStyle); Element.setStyle(this.closer, { 'width': buttonDimensions[0] + 'px', 'height': buttonDimensions[1] + 'px', 'cursor': 'pointer', 'position': 'absolute', 'top': '0px', 'right': '0px' }); Element.setStyle(this.content, { 'width': contentDimensions[0] + 'px', 'height': (contentDimensions[1] - this.button.height) + 'px', 'overflow': 'auto', 'position': 'absolute', 'top': buttonDimensions[1] + 'px', 'left': '0px', 'fontFamily': 'verdana', 'fontSize': '11px', 'fontWeight': 'normal', 'color': 'black' }); }, /** * Gets the current position of the obj * @param {Element} element to get position of * @return Object of (x, y, x2, y2) */ _getXY: function(obj) { var pos = Position.cumulativeOffset(obj) var y = pos[1]; var x = pos[0]; var x2 = x + parseInt(obj.offsetWidth); var y2 = y + parseInt(obj.offsetHeight); return {'x':x, 'y':y, 'x2':x2, 'y2':y2}; }, /** * Determins if the object is a child of the balloon element * @param {Element} Element to check parentage * @return {Boolean} * @private */ _isChild: function(obj) { var i = 15; do{ if(obj == this.container) return true; obj = obj.parentNode; }while(obj && i--); return false }, /** * Determines if the balloon is over this_obj object * @param {Element} Object to look under * @return {Boolean} * @private */ _isOver: function(this_obj) { if(!this.visible) return false; if(this_obj == this.container || this._isChild(this_obj)) return false; var this_coords = this._getXY(this_obj); var that_coords = this._getXY(this.container); if( ( ( (this_coords.x >= that_coords.x && this_coords.x <= that_coords.x2) || (this_coords.x2 >= that_coords.x && this_coords.x2 <= that_coords.x2) ) && ( (this_coords.y >= that_coords.y && this_coords.y <= that_coords.y2) || (this_coords.y2 >= that_coords.y && this_coords.y2 <= that_coords.y2) ) ) ){ return true; } else return false; }, /** * Restores visibility of elements under the balloon * (For IE) * TODO: suck yourself * @private */ _showLowerElements: function() { if(this.options.hideUnderElementsInIE) { var elements = this._getWeirdAPIElements(); for(var i = 0; i < elements.length; i++) { if(this._isOver(elements[i])) { if(elements[i].style.visibility != 'visible' && elements[i].hiddenBy == this) { elements[i].style.visibility = 'visible'; elements[i].hiddenBy = null; } } } } }, /** * Hides elements below the balloon * (For IE) * @private */ _hideLowerElements: function() { if(this.options.hideUnderElementsInIE) { var elements = this._getWeirdAPIElements(); for(var i = 0; i < elements.length; i++) { if(this._isOver(elements[i])) { if(elements[i].style.visibility != 'hidden') { elements[i].style.visibility = 'hidden'; elements[i].hiddenBy = this; } } } } }, /** * Determines which elements need to be hidden * (For IE) * @return {Array} array of elements */ _getWeirdAPIElements: function() { if(!Prototype.Browser.IE) return []; var objs = ['select', 'input', 'object']; var elements = []; for(var i = 0; i < objs.length; i++) { var e = document.getElementsByTagName(objs[i]); for(var j = 0; j < e.length; j++) { elements.push(e[j]); } } return elements; }, /** * Hides the other visible help balloons * @param {Event} e */ _hideOtherHelps: function(e) { if(this.options.hideOtherBalloonsOnDisplay) { $A(HelpBalloon._balloons).each(function(obj){ if(obj != this) { obj.hide(); } }.bind(this)); } } }; /** * HelpBalloon.Options * Helper class for defining options for the HelpBalloon object * @author Beau D. Scott <beau_scott@hotmail.com> */ HelpBalloon.Options = Class.create(); HelpBalloon.Options.prototype = { /** * @constructor * @param {Object} overriding options */ initialize: function(values){ // Apply the overriding values to this Object.extend(this, values || {}); }, /** * Show Effect * The Scriptaculous (or compatible) showing effect function * @var Function */ showEffect: window.Scriptaculous ? Effect.Appear : null, /** * Show Effect options */ showEffectOptions: {duration: 0.2}, /** * Hide Effect * The Scriptaculous (or compatible) hiding effect function * @var Function */ hideEffect: window.Scriptaculous ? Effect.Fade : null, /** * Show Effect options */ hideEffectOptions: {duration: 0.2}, /** * For use with embedding this object into another. If true, the icon is not created * and not appeneded to the DOM at construction. * Default is false * @var {Boolean} */ returnElement: false, /** * URL to the anchoring icon image file to use. This can also be a direct reference * to an existing element if you're using that as your anchoring icon. * @var {Object} */ icon: 'images/icon.gif', /** * Alt text of the help icon * @var {String} */ altText: 'Click here for help with this topic.', /** * URL to pull the title/content XML * @var {String} */ dataURL: null, /** * Static title of the balloon * @var {String} */ title: null, /** * Static content of the balloon * @var {String} */ content: null, /** * The event type to listen for on the icon to show the balloon. * Default 'click' * @var {String} */ useEvent: ['click'], /** * Request method for dynamic content. (get, post) * Default 'get' * @var {String} */ method: 'get', /** * Flag indicating cache the request result. If this is false, every * time the balloon is shown, it will retrieve the remote url and parse it * before the balloon appears, updating the content. Otherwise, it will make * the call once and use the same content with each subsequent showing. * Default true * @var {Boolean} */ cacheRemoteContent: true, /** * Vertical and horizontal margin of the content pane * @var {Number} */ contentMargin: 35, /** * X coordinate of the closing button * @var {Number} */ buttonX: 246, /** * Y coordinate of the closing button * @var {Number} */ buttonY: 35, /** * Closing button image path * @var {String} */ button: 'images/button.png', /** * Balloon image path prefix. There are 4 button images, numerically named, starting with 0. * 0 1 * X * 2 3 * X indicates the anchor corner * @var {String} */ balloonPrefix: 'images/balloon-', /** * The image filename suffix, including the file extension * @var {String} */ balloonSuffix: '.png', /** * Position of the balloon's anchor relative to the icon element. * Combine one horizontal indicator (left, center, right) and one vertical indicator (top, middle, bottom). * Numeric values can also be used in an X Y order. So a value of 9 13 would place the anchor 9 pixels from * the left and 13 pixels below the top. (0,0 is top left). If values are greater than the width or height * the width or height of the anchor are used instead. If less than 0, 0 is used. * Default is 'center middle' * @var {String} */ anchorPosition: 'center middle', /** * Flag indicating whether to hide the elements under the balloon in IE. * Setting this to false can cause rendering issues in Internet Explorer * as some elements appear on top of the balloon if they're not hidden. * Default is true. * @var {Boolean} */ hideUnderElementsInIE: true, /** * Default Balloon styling * @var {Object} */ balloonStyle: {}, /** * Default Title Bar style * @var {Object} */ titleStyle: { 'color': 'black', 'fontSize': '16px', 'fontWeight': 'bold', 'fontFamily': 'Verdana' }, /** * Icon custom styling * @var {Object} */ iconStyle: { 'cursor': 'pointer' }, /** * Flag indication whether to automatically hide any other visible HelpBalloon on the page before showing the current one. * @var {Boolean} */ hideOtherBalloonsOnDisplay: true, /** * If you want the balloon to always display in a particular location, set this */ fixedPosition: HelpBalloon.POS_DYNAMIC, /** * Number of milliseconds to hide the balloon after showing and after the mouse is not over the balloon. * A value of 0 means it will not auto-hide * @var {Number} */ autoHideTimeout: 0 };