/** * tooltipster http://iamceege.github.io/tooltipster/ * A rockin' custom tooltip jQuery plugin * Developed by Caleb Jacob and Louis Ameline * MIT license */ (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module unless amdModuleId is set define(["jquery"], function (a0) { return (factory(a0)); }); } else if (typeof exports === 'object') { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. module.exports = factory(require("jquery")); } else { factory(jQuery); } }(this, function ($) { // This file will be UMDified by a build task. var defaults = { animation: 'fade', animationDuration: 350, content: null, contentAsHTML: false, contentCloning: false, debug: true, delay: 300, delayTouch: [300, 500], functionInit: null, functionBefore: null, functionReady: null, functionAfter: null, functionFormat: null, IEmin: 6, interactive: false, multiple: false, // will default to document.body, or must be an element positioned at (0, 0) // in the document, typically like the very top views of an app. parent: null, plugins: ['sideTip'], repositionOnScroll: false, restoration: 'none', selfDestruction: true, theme: [], timer: 0, trackerInterval: 500, trackOrigin: false, trackTooltip: false, trigger: 'hover', triggerClose: { click: false, mouseleave: false, originClick: false, scroll: false, tap: false, touchleave: false }, triggerOpen: { click: false, mouseenter: false, tap: false, touchstart: false }, updateAnimation: 'rotate', zIndex: 9999999 }, // we'll avoid using the 'window' global as a good practice but npm's // jquery@<2.1.0 package actually requires a 'window' global, so not sure // it's useful at all win = (typeof window != 'undefined') ? window : null, // env will be proxied by the core for plugins to have access its properties env = { // detect if this device can trigger touch events. Better have a false // positive (unused listeners, that's ok) than a false negative. // https://github.com/Modernizr/Modernizr/blob/master/feature-detects/touchevents.js // http://stackoverflow.com/questions/4817029/whats-the-best-way-to-detect-a-touch-screen-device-using-javascript hasTouchCapability: !!( win && ( 'ontouchstart' in win || (win.DocumentTouch && win.document instanceof win.DocumentTouch) || win.navigator.maxTouchPoints ) ), hasTransitions: transitionSupport(), IE: false, // don't set manually, it will be updated by a build task after the manifest semVer: '4.2.5', window: win }, core = function() { // core variables // the core emitters this.__$emitterPrivate = $({}); this.__$emitterPublic = $({}); this.__instancesLatestArr = []; // collects plugin constructors this.__plugins = {}; // proxy env variables for plugins who might use them this._env = env; }; // core methods core.prototype = { /** * A function to proxy the public methods of an object onto another * * @param {object} constructor The constructor to bridge * @param {object} obj The object that will get new methods (an instance or the core) * @param {string} pluginName A plugin name for the console log message * @return {core} * @private */ __bridge: function(constructor, obj, pluginName) { // if it's not already bridged if (!obj[pluginName]) { var fn = function() {}; fn.prototype = constructor; var pluginInstance = new fn(); // the _init method has to exist in instance constructors but might be missing // in core constructors if (pluginInstance.__init) { pluginInstance.__init(obj); } $.each(constructor, function(methodName, fn) { // don't proxy "private" methods, only "protected" and public ones if (methodName.indexOf('__') != 0) { // if the method does not exist yet if (!obj[methodName]) { obj[methodName] = function() { return pluginInstance[methodName].apply(pluginInstance, Array.prototype.slice.apply(arguments)); }; // remember to which plugin this method corresponds (several plugins may // have methods of the same name, we need to be sure) obj[methodName].bridged = pluginInstance; } else if (defaults.debug) { console.log('The '+ methodName +' method of the '+ pluginName +' plugin conflicts with another plugin or native methods'); } } }); obj[pluginName] = pluginInstance; } return this; }, /** * For mockup in Node env if need be, for testing purposes * * @return {core} * @private */ __setWindow: function(window) { env.window = window; return this; }, /** * Returns a ruler, a tool to help measure the size of a tooltip under * various settings. Meant for plugins * * @see Ruler * @return {object} A Ruler instance * @protected */ _getRuler: function($tooltip) { return new Ruler($tooltip); }, /** * For internal use by plugins, if needed * * @return {core} * @protected */ _off: function() { this.__$emitterPrivate.off.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * For internal use by plugins, if needed * * @return {core} * @protected */ _on: function() { this.__$emitterPrivate.on.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * For internal use by plugins, if needed * * @return {core} * @protected */ _one: function() { this.__$emitterPrivate.one.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * Returns (getter) or adds (setter) a plugin * * @param {string|object} plugin Provide a string (in the full form * "namespace.name") to use as as getter, an object to use as a setter * @return {object|core} * @protected */ _plugin: function(plugin) { var self = this; // getter if (typeof plugin == 'string') { var pluginName = plugin, p = null; // if the namespace is provided, it's easy to search if (pluginName.indexOf('.') > 0) { p = self.__plugins[pluginName]; } // otherwise, return the first name that matches else { $.each(self.__plugins, function(i, plugin) { if (plugin.name.substring(plugin.name.length - pluginName.length - 1) == '.'+ pluginName) { p = plugin; return false; } }); } return p; } // setter else { // force namespaces if (plugin.name.indexOf('.') < 0) { throw new Error('Plugins must be namespaced'); } self.__plugins[plugin.name] = plugin; // if the plugin has core features if (plugin.core) { // bridge non-private methods onto the core to allow new core methods self.__bridge(plugin.core, self, plugin.name); } return this; } }, /** * Trigger events on the core emitters * * @returns {core} * @protected */ _trigger: function() { var args = Array.prototype.slice.apply(arguments); if (typeof args[0] == 'string') { args[0] = { type: args[0] }; } // note: the order of emitters matters this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate, args); this.__$emitterPublic.trigger.apply(this.__$emitterPublic, args); return this; }, /** * Returns instances of all tooltips in the page or an a given element * * @param {string|HTML object collection} selector optional Use this * parameter to restrict the set of objects that will be inspected * for the retrieval of instances. By default, all instances in the * page are returned. * @return {array} An array of instance objects * @public */ instances: function(selector) { var instances = [], sel = selector || '.tooltipstered'; $(sel).each(function() { var $this = $(this), ns = $this.data('tooltipster-ns'); if (ns) { $.each(ns, function(i, namespace) { instances.push($this.data(namespace)); }); } }); return instances; }, /** * Returns the Tooltipster objects generated by the last initializing call * * @return {array} An array of instance objects * @public */ instancesLatest: function() { return this.__instancesLatestArr; }, /** * For public use only, not to be used by plugins (use ::_off() instead) * * @return {core} * @public */ off: function() { this.__$emitterPublic.off.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); return this; }, /** * For public use only, not to be used by plugins (use ::_on() instead) * * @return {core} * @public */ on: function() { this.__$emitterPublic.on.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); return this; }, /** * For public use only, not to be used by plugins (use ::_one() instead) * * @return {core} * @public */ one: function() { this.__$emitterPublic.one.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); return this; }, /** * Returns all HTML elements which have one or more tooltips * * @param {string} selector optional Use this to restrict the results * to the descendants of an element * @return {array} An array of HTML elements * @public */ origins: function(selector) { var sel = selector ? selector +' ' : ''; return $(sel +'.tooltipstered').toArray(); }, /** * Change default options for all future instances * * @param {object} d The options that should be made defaults * @return {core} * @public */ setDefaults: function(d) { $.extend(defaults, d); return this; }, /** * For users to trigger their handlers on the public emitter * * @returns {core} * @public */ triggerHandler: function() { this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); return this; } }; // $.tooltipster will be used to call core methods $.tooltipster = new core(); // the Tooltipster instance class (mind the capital T) $.Tooltipster = function(element, options) { // list of instance variables // stack of custom callbacks provided as parameters to API methods this.__callbacks = { close: [], open: [] }; // the schedule time of DOM removal this.__closingTime; // this will be the user content shown in the tooltip. A capital "C" is used // because there is also a method called content() this.__Content; // for the size tracker this.__contentBcr; // to disable the tooltip after destruction this.__destroyed = false; // we can't emit directly on the instance because if a method with the same // name as the event exists, it will be called by jQuery. Se we use a plain // object as emitter. This emitter is for internal use by plugins, // if needed. this.__$emitterPrivate = $({}); // this emitter is for the user to listen to events without risking to mess // with our internal listeners this.__$emitterPublic = $({}); this.__enabled = true; // the reference to the gc interval this.__garbageCollector; // various position and size data recomputed before each repositioning this.__Geometry; // the tooltip position, saved after each repositioning by a plugin this.__lastPosition; // a unique namespace per instance this.__namespace = 'tooltipster-'+ Math.round(Math.random()*1000000); this.__options; // will be used to support origins in scrollable areas this.__$originParents; this.__pointerIsOverOrigin = false; // to remove themes if needed this.__previousThemes = []; // the state can be either: appearing, stable, disappearing, closed this.__state = 'closed'; // timeout references this.__timeouts = { close: [], open: null }; // store touch events to be able to detect emulated mouse events this.__touchEvents = []; // the reference to the tracker interval this.__tracker = null; // the element to which this tooltip is associated this._$origin; // this will be the tooltip element (jQuery wrapped HTML element). // It's the job of a plugin to create it and append it to the DOM this._$tooltip; // launch this.__init(element, options); }; $.Tooltipster.prototype = { /** * @param origin * @param options * @private */ __init: function(origin, options) { var self = this; self._$origin = $(origin); self.__options = $.extend(true, {}, defaults, options); // some options may need to be reformatted self.__optionsFormat(); // don't run on old IE if asked no to if ( !env.IE || env.IE >= self.__options.IEmin ) { // note: the content is null (empty) by default and can stay that // way if the plugin remains initialized but not fed any content. The // tooltip will just not appear. // let's save the initial value of the title attribute for later // restoration if need be. var initialTitle = null; // it will already have been saved in case of multiple tooltips if (self._$origin.data('tooltipster-initialTitle') === undefined) { initialTitle = self._$origin.attr('title'); // we do not want initialTitle to be "undefined" because // of how jQuery's .data() method works if (initialTitle === undefined) initialTitle = null; self._$origin.data('tooltipster-initialTitle', initialTitle); } // If content is provided in the options, it has precedence over the // title attribute. // Note: an empty string is considered content, only 'null' represents // the absence of content. // Also, an existing title="" attribute will result in an empty string // content if (self.__options.content !== null) { self.__contentSet(self.__options.content); } else { var selector = self._$origin.attr('data-tooltip-content'), $el; if (selector){ $el = $(selector); } if ($el && $el[0]) { self.__contentSet($el.first()); } else { self.__contentSet(initialTitle); } } self._$origin // strip the title off of the element to prevent the default tooltips // from popping up .removeAttr('title') // to be able to find all instances on the page later (upon window // events in particular) .addClass('tooltipstered'); // set listeners on the origin self.__prepareOrigin(); // set the garbage collector self.__prepareGC(); // init plugins $.each(self.__options.plugins, function(i, pluginName) { self._plug(pluginName); }); // to detect swiping if (env.hasTouchCapability) { $(env.window.document.body).on('touchmove.'+ self.__namespace +'-triggerOpen', function(event) { self._touchRecordEvent(event); }); } self // prepare the tooltip when it gets created. This event must // be fired by a plugin ._on('created', function() { self.__prepareTooltip(); }) // save position information when it's sent by a plugin ._on('repositioned', function(e) { self.__lastPosition = e.position; }); } else { self.__options.disabled = true; } }, /** * Insert the content into the appropriate HTML element of the tooltip * * @returns {self} * @private */ __contentInsert: function() { var self = this, $el = self._$tooltip.find('.tooltipster-content'), formattedContent = self.__Content, format = function(content) { formattedContent = content; }; self._trigger({ type: 'format', content: self.__Content, format: format }); if (self.__options.functionFormat) { formattedContent = self.__options.functionFormat.call( self, self, { origin: self._$origin[0] }, self.__Content ); } if (typeof formattedContent === 'string' && !self.__options.contentAsHTML) { $el.text(formattedContent); } else { $el .empty() .append(formattedContent); } return self; }, /** * Save the content, cloning it beforehand if need be * * @param content * @returns {self} * @private */ __contentSet: function(content) { // clone if asked. Cloning the object makes sure that each instance has its // own version of the content (in case a same object were provided for several // instances) // reminder: typeof null === object if (content instanceof $ && this.__options.contentCloning) { content = content.clone(true); } this.__Content = content; this._trigger({ type: 'updated', content: content }); return this; }, /** * Error message about a method call made after destruction * * @private */ __destroyError: function() { throw new Error('This tooltip has been destroyed and cannot execute your method call.'); }, /** * Gather all information about dimensions and available space, * called before every repositioning * * @private * @returns {object} */ __geometry: function() { var self = this, $target = self._$origin, originIsArea = self._$origin.is('area'); // if this._$origin is a map area, the target we'll need // the dimensions of is actually the image using the map, // not the area itself if (originIsArea) { var mapName = self._$origin.parent().attr('name'); $target = $('img[usemap="#'+ mapName +'"]'); } var bcr = $target[0].getBoundingClientRect(), $document = $(env.window.document), $window = $(env.window), $parent = $target, // some useful properties of important elements geo = { // available space for the tooltip, see down below available: { document: null, window: null }, document: { size: { height: $document.height(), width: $document.width() } }, window: { scroll: { // the second ones are for IE compatibility left: env.window.scrollX || env.window.document.documentElement.scrollLeft, top: env.window.scrollY || env.window.document.documentElement.scrollTop }, size: { height: $window.height(), width: $window.width() } }, origin: { // the origin has a fixed lineage if itself or one of its // ancestors has a fixed position fixedLineage: false, // relative to the document offset: {}, size: { height: bcr.bottom - bcr.top, width: bcr.right - bcr.left }, usemapImage: originIsArea ? $target[0] : null, // relative to the window windowOffset: { bottom: bcr.bottom, left: bcr.left, right: bcr.right, top: bcr.top } } }, geoFixed = false; // if the element is a map area, some properties may need // to be recalculated if (originIsArea) { var shape = self._$origin.attr('shape'), coords = self._$origin.attr('coords'); if (coords) { coords = coords.split(','); $.map(coords, function(val, i) { coords[i] = parseInt(val); }); } // if the image itself is the area, nothing more to do if (shape != 'default') { switch(shape) { case 'circle': var circleCenterLeft = coords[0], circleCenterTop = coords[1], circleRadius = coords[2], areaTopOffset = circleCenterTop - circleRadius, areaLeftOffset = circleCenterLeft - circleRadius; geo.origin.size.height = circleRadius * 2; geo.origin.size.width = geo.origin.size.height; geo.origin.windowOffset.left += areaLeftOffset; geo.origin.windowOffset.top += areaTopOffset; break; case 'rect': var areaLeft = coords[0], areaTop = coords[1], areaRight = coords[2], areaBottom = coords[3]; geo.origin.size.height = areaBottom - areaTop; geo.origin.size.width = areaRight - areaLeft; geo.origin.windowOffset.left += areaLeft; geo.origin.windowOffset.top += areaTop; break; case 'poly': var areaSmallestX = 0, areaSmallestY = 0, areaGreatestX = 0, areaGreatestY = 0, arrayAlternate = 'even'; for (var i = 0; i < coords.length; i++) { var areaNumber = coords[i]; if (arrayAlternate == 'even') { if (areaNumber > areaGreatestX) { areaGreatestX = areaNumber; if (i === 0) { areaSmallestX = areaGreatestX; } } if (areaNumber < areaSmallestX) { areaSmallestX = areaNumber; } arrayAlternate = 'odd'; } else { if (areaNumber > areaGreatestY) { areaGreatestY = areaNumber; if (i == 1) { areaSmallestY = areaGreatestY; } } if (areaNumber < areaSmallestY) { areaSmallestY = areaNumber; } arrayAlternate = 'even'; } } geo.origin.size.height = areaGreatestY - areaSmallestY; geo.origin.size.width = areaGreatestX - areaSmallestX; geo.origin.windowOffset.left += areaSmallestX; geo.origin.windowOffset.top += areaSmallestY; break; } } } // user callback through an event var edit = function(r) { geo.origin.size.height = r.height, geo.origin.windowOffset.left = r.left, geo.origin.windowOffset.top = r.top, geo.origin.size.width = r.width }; self._trigger({ type: 'geometry', edit: edit, geometry: { height: geo.origin.size.height, left: geo.origin.windowOffset.left, top: geo.origin.windowOffset.top, width: geo.origin.size.width } }); // calculate the remaining properties with what we got geo.origin.windowOffset.right = geo.origin.windowOffset.left + geo.origin.size.width; geo.origin.windowOffset.bottom = geo.origin.windowOffset.top + geo.origin.size.height; geo.origin.offset.left = geo.origin.windowOffset.left + geo.window.scroll.left; geo.origin.offset.top = geo.origin.windowOffset.top + geo.window.scroll.top; geo.origin.offset.bottom = geo.origin.offset.top + geo.origin.size.height; geo.origin.offset.right = geo.origin.offset.left + geo.origin.size.width; // the space that is available to display the tooltip relatively to the document geo.available.document = { bottom: { height: geo.document.size.height - geo.origin.offset.bottom, width: geo.document.size.width }, left: { height: geo.document.size.height, width: geo.origin.offset.left }, right: { height: geo.document.size.height, width: geo.document.size.width - geo.origin.offset.right }, top: { height: geo.origin.offset.top, width: geo.document.size.width } }; // the space that is available to display the tooltip relatively to the viewport // (the resulting values may be negative if the origin overflows the viewport) geo.available.window = { bottom: { // the inner max is here to make sure the available height is no bigger // than the viewport height (when the origin is off screen at the top). // The outer max just makes sure that the height is not negative (when // the origin overflows at the bottom). height: Math.max(geo.window.size.height - Math.max(geo.origin.windowOffset.bottom, 0), 0), width: geo.window.size.width }, left: { height: geo.window.size.height, width: Math.max(geo.origin.windowOffset.left, 0) }, right: { height: geo.window.size.height, width: Math.max(geo.window.size.width - Math.max(geo.origin.windowOffset.right, 0), 0) }, top: { height: Math.max(geo.origin.windowOffset.top, 0), width: geo.window.size.width } }; while ($parent[0].tagName.toLowerCase() != 'html') { if ($parent.css('position') == 'fixed') { geo.origin.fixedLineage = true; break; } $parent = $parent.parent(); } return geo; }, /** * Some options may need to be formated before being used * * @returns {self} * @private */ __optionsFormat: function() { if (typeof this.__options.animationDuration == 'number') { this.__options.animationDuration = [this.__options.animationDuration, this.__options.animationDuration]; } if (typeof this.__options.delay == 'number') { this.__options.delay = [this.__options.delay, this.__options.delay]; } if (typeof this.__options.delayTouch == 'number') { this.__options.delayTouch = [this.__options.delayTouch, this.__options.delayTouch]; } if (typeof this.__options.theme == 'string') { this.__options.theme = [this.__options.theme]; } // determine the future parent if (this.__options.parent === null) { this.__options.parent = $(env.window.document.body); } else if (typeof this.__options.parent == 'string') { this.__options.parent = $(this.__options.parent); } if (this.__options.trigger == 'hover') { this.__options.triggerOpen = { mouseenter: true, touchstart: true }; this.__options.triggerClose = { mouseleave: true, originClick: true, touchleave: true }; } else if (this.__options.trigger == 'click') { this.__options.triggerOpen = { click: true, tap: true }; this.__options.triggerClose = { click: true, tap: true }; } // for the plugins this._trigger('options'); return this; }, /** * Schedules or cancels the garbage collector task * * @returns {self} * @private */ __prepareGC: function() { var self = this; // in case the selfDestruction option has been changed by a method call if (self.__options.selfDestruction) { // the GC task self.__garbageCollector = setInterval(function() { var now = new Date().getTime(); // forget the old events self.__touchEvents = $.grep(self.__touchEvents, function(event, i) { // 1 minute return now - event.time > 60000; }); // auto-destruct if the origin is gone if (!bodyContains(self._$origin)) { self.close(function(){ self.destroy(); }); } }, 20000); } else { clearInterval(self.__garbageCollector); } return self; }, /** * Sets listeners on the origin if the open triggers require them. * Unlike the listeners set at opening time, these ones * remain even when the tooltip is closed. It has been made a * separate method so it can be called when the triggers are * changed in the options. Closing is handled in _open() * because of the bindings that may be needed on the tooltip * itself * * @returns {self} * @private */ __prepareOrigin: function() { var self = this; // in case we're resetting the triggers self._$origin.off('.'+ self.__namespace +'-triggerOpen'); // if the device is touch capable, even if only mouse triggers // are asked, we need to listen to touch events to know if the mouse // events are actually emulated (so we can ignore them) if (env.hasTouchCapability) { self._$origin.on( 'touchstart.'+ self.__namespace +'-triggerOpen ' + 'touchend.'+ self.__namespace +'-triggerOpen ' + 'touchcancel.'+ self.__namespace +'-triggerOpen', function(event){ self._touchRecordEvent(event); } ); } // mouse click and touch tap work the same way if ( self.__options.triggerOpen.click || (self.__options.triggerOpen.tap && env.hasTouchCapability) ) { var eventNames = ''; if (self.__options.triggerOpen.click) { eventNames += 'click.'+ self.__namespace +'-triggerOpen '; } if (self.__options.triggerOpen.tap && env.hasTouchCapability) { eventNames += 'touchend.'+ self.__namespace +'-triggerOpen'; } self._$origin.on(eventNames, function(event) { if (self._touchIsMeaningfulEvent(event)) { self._open(event); } }); } // mouseenter and touch start work the same way if ( self.__options.triggerOpen.mouseenter || (self.__options.triggerOpen.touchstart && env.hasTouchCapability) ) { var eventNames = ''; if (self.__options.triggerOpen.mouseenter) { eventNames += 'mouseenter.'+ self.__namespace +'-triggerOpen '; } if (self.__options.triggerOpen.touchstart && env.hasTouchCapability) { eventNames += 'touchstart.'+ self.__namespace +'-triggerOpen'; } self._$origin.on(eventNames, function(event) { if ( self._touchIsTouchEvent(event) || !self._touchIsEmulatedEvent(event) ) { self.__pointerIsOverOrigin = true; self._openShortly(event); } }); } // info for the mouseleave/touchleave close triggers when they use a delay if ( self.__options.triggerClose.mouseleave || (self.__options.triggerClose.touchleave && env.hasTouchCapability) ) { var eventNames = ''; if (self.__options.triggerClose.mouseleave) { eventNames += 'mouseleave.'+ self.__namespace +'-triggerOpen '; } if (self.__options.triggerClose.touchleave && env.hasTouchCapability) { eventNames += 'touchend.'+ self.__namespace +'-triggerOpen touchcancel.'+ self.__namespace +'-triggerOpen'; } self._$origin.on(eventNames, function(event) { if (self._touchIsMeaningfulEvent(event)) { self.__pointerIsOverOrigin = false; } }); } return self; }, /** * Do the things that need to be done only once after the tooltip * HTML element it has been created. It has been made a separate * method so it can be called when options are changed. Remember * that the tooltip may actually exist in the DOM before it is * opened, and present after it has been closed: it's the display * plugin that takes care of handling it. * * @returns {self} * @private */ __prepareTooltip: function() { var self = this, p = self.__options.interactive ? 'auto' : ''; // this will be useful to know quickly if the tooltip is in // the DOM or not self._$tooltip .attr('id', self.__namespace) .css({ // pointer events 'pointer-events': p, zIndex: self.__options.zIndex }); // themes // remove the old ones and add the new ones $.each(self.__previousThemes, function(i, theme) { self._$tooltip.removeClass(theme); }); $.each(self.__options.theme, function(i, theme) { self._$tooltip.addClass(theme); }); self.__previousThemes = $.merge([], self.__options.theme); return self; }, /** * Handles the scroll on any of the parents of the origin (when the * tooltip is open) * * @param {object} event * @returns {self} * @private */ __scrollHandler: function(event) { var self = this; if (self.__options.triggerClose.scroll) { self._close(event); } else { // if the origin or tooltip have been removed: do nothing, the tracker will // take care of it later if (bodyContains(self._$origin) && bodyContains(self._$tooltip)) { var geo = null; // if the scroll happened on the window if (event.target === env.window.document) { // if the origin has a fixed lineage, window scroll will have no // effect on its position nor on the position of the tooltip if (!self.__Geometry.origin.fixedLineage) { // we don't need to do anything unless repositionOnScroll is true // because the tooltip will already have moved with the window // (and of course with the origin) if (self.__options.repositionOnScroll) { self.reposition(event); } } } // if the scroll happened on another parent of the tooltip, it means // that it's in a scrollable area and now needs to have its position // adjusted or recomputed, depending ont the repositionOnScroll // option. Also, if the origin is partly hidden due to a parent that // hides its overflow, we'll just hide (not close) the tooltip. else { geo = self.__geometry(); var overflows = false; // a fixed position origin is not affected by the overflow hiding // of a parent if (self._$origin.css('position') != 'fixed') { self.__$originParents.each(function(i, el) { var $el = $(el), overflowX = $el.css('overflow-x'), overflowY = $el.css('overflow-y'); if (overflowX != 'visible' || overflowY != 'visible') { var bcr = el.getBoundingClientRect(); if (overflowX != 'visible') { if ( geo.origin.windowOffset.left < bcr.left || geo.origin.windowOffset.right > bcr.right ) { overflows = true; return false; } } if (overflowY != 'visible') { if ( geo.origin.windowOffset.top < bcr.top || geo.origin.windowOffset.bottom > bcr.bottom ) { overflows = true; return false; } } } // no need to go further if fixed, for the same reason as above if ($el.css('position') == 'fixed') { return false; } }); } if (overflows) { self._$tooltip.css('visibility', 'hidden'); } else { self._$tooltip.css('visibility', 'visible'); // reposition if (self.__options.repositionOnScroll) { self.reposition(event); } // or just adjust offset else { // we have to use offset and not windowOffset because this way, // only the scroll distance of the scrollable areas are taken into // account (the scrolltop value of the main window must be // ignored since the tooltip already moves with it) var offsetLeft = geo.origin.offset.left - self.__Geometry.origin.offset.left, offsetTop = geo.origin.offset.top - self.__Geometry.origin.offset.top; // add the offset to the position initially computed by the display plugin self._$tooltip.css({ left: self.__lastPosition.coord.left + offsetLeft, top: self.__lastPosition.coord.top + offsetTop }); } } } self._trigger({ type: 'scroll', event: event, geo: geo }); } } return self; }, /** * Changes the state of the tooltip * * @param {string} state * @returns {self} * @private */ __stateSet: function(state) { this.__state = state; this._trigger({ type: 'state', state: state }); return this; }, /** * Clear appearance timeouts * * @returns {self} * @private */ __timeoutsClear: function() { // there is only one possible open timeout: the delayed opening // when the mouseenter/touchstart open triggers are used clearTimeout(this.__timeouts.open); this.__timeouts.open = null; // ... but several close timeouts: the delayed closing when the // mouseleave close trigger is used and the timer option $.each(this.__timeouts.close, function(i, timeout) { clearTimeout(timeout); }); this.__timeouts.close = []; return this; }, /** * Start the tracker that will make checks at regular intervals * * @returns {self} * @private */ __trackerStart: function() { var self = this, $content = self._$tooltip.find('.tooltipster-content'); // get the initial content size if (self.__options.trackTooltip) { self.__contentBcr = $content[0].getBoundingClientRect(); } self.__tracker = setInterval(function() { // if the origin or tooltip elements have been removed. // Note: we could destroy the instance now if the origin has // been removed but we'll leave that task to our garbage collector if (!bodyContains(self._$origin) || !bodyContains(self._$tooltip)) { self._close(); } // if everything is alright else { // compare the former and current positions of the origin to reposition // the tooltip if need be if (self.__options.trackOrigin) { var g = self.__geometry(), identical = false; // compare size first (a change requires repositioning too) if (areEqual(g.origin.size, self.__Geometry.origin.size)) { // for elements that have a fixed lineage (see __geometry()), we track the // top and left properties (relative to window) if (self.__Geometry.origin.fixedLineage) { if (areEqual(g.origin.windowOffset, self.__Geometry.origin.windowOffset)) { identical = true; } } // otherwise, track total offset (relative to document) else { if (areEqual(g.origin.offset, self.__Geometry.origin.offset)) { identical = true; } } } if (!identical) { // close the tooltip when using the mouseleave close trigger // (see https://github.com/iamceege/tooltipster/pull/253) if (self.__options.triggerClose.mouseleave) { self._close(); } else { self.reposition(); } } } if (self.__options.trackTooltip) { var currentBcr = $content[0].getBoundingClientRect(); if ( currentBcr.height !== self.__contentBcr.height || currentBcr.width !== self.__contentBcr.width ) { self.reposition(); self.__contentBcr = currentBcr; } } } }, self.__options.trackerInterval); return self; }, /** * Closes the tooltip (after the closing delay) * * @param event * @param callback * @param force Set to true to override a potential refusal of the user's function * @returns {self} * @protected */ _close: function(event, callback, force) { var self = this, ok = true; self._trigger({ type: 'close', event: event, stop: function() { ok = false; } }); // a destroying tooltip (force == true) may not refuse to close if (ok || force) { // save the method custom callback and cancel any open method custom callbacks if (callback) self.__callbacks.close.push(callback); self.__callbacks.open = []; // clear open/close timeouts self.__timeoutsClear(); var finishCallbacks = function() { // trigger any close method custom callbacks and reset them $.each(self.__callbacks.close, function(i,c) { c.call(self, self, { event: event, origin: self._$origin[0] }); }); self.__callbacks.close = []; }; if (self.__state != 'closed') { var necessary = true, d = new Date(), now = d.getTime(), newClosingTime = now + self.__options.animationDuration[1]; // the tooltip may already already be disappearing, but if a new // call to close() is made after the animationDuration was changed // to 0 (for example), we ought to actually close it sooner than // previously scheduled. In that case it should be noted that the // browser will not adapt the animation duration to the new // animationDuration that was set after the start of the closing // animation. // Note: the same thing could be considered at opening, but is not // really useful since the tooltip is actually opened immediately // upon a call to _open(). Since it would not make the opening // animation finish sooner, its sole impact would be to trigger the // state event and the open callbacks sooner than the actual end of // the opening animation, which is not great. if (self.__state == 'disappearing') { if ( newClosingTime > self.__closingTime // in case closing is actually overdue because the script // execution was suspended. See #679 && self.__options.animationDuration[1] > 0 ) { necessary = false; } } if (necessary) { self.__closingTime = newClosingTime; if (self.__state != 'disappearing') { self.__stateSet('disappearing'); } var finish = function() { // stop the tracker clearInterval(self.__tracker); // a "beforeClose" option has been asked several times but would // probably useless since the content element is still accessible // via ::content(), and because people can always use listeners // inside their content to track what's going on. For the sake of // simplicity, this has been denied. Bur for the rare people who // really need the option (for old browsers or for the case where // detaching the content is actually destructive, for file or // password inputs for example), this event will do the work. self._trigger({ type: 'closing', event: event }); // unbind listeners which are no longer needed self._$tooltip .off('.'+ self.__namespace +'-triggerClose') .removeClass('tooltipster-dying'); // orientationchange, scroll and resize listeners $(env.window).off('.'+ self.__namespace +'-triggerClose'); // scroll listeners self.__$originParents.each(function(i, el) { $(el).off('scroll.'+ self.__namespace +'-triggerClose'); }); // clear the array to prevent memory leaks self.__$originParents = null; $(env.window.document.body).off('.'+ self.__namespace +'-triggerClose'); self._$origin.off('.'+ self.__namespace +'-triggerClose'); self._off('dismissable'); // a plugin that would like to remove the tooltip from the // DOM when closed should bind on this self.__stateSet('closed'); // trigger event self._trigger({ type: 'after', event: event }); // call our constructor custom callback function if (self.__options.functionAfter) { self.__options.functionAfter.call(self, self, { event: event, origin: self._$origin[0] }); } // call our method custom callbacks functions finishCallbacks(); }; if (env.hasTransitions) { self._$tooltip.css({ '-moz-animation-duration': self.__options.animationDuration[1] + 'ms', '-ms-animation-duration': self.__options.animationDuration[1] + 'ms', '-o-animation-duration': self.__options.animationDuration[1] + 'ms', '-webkit-animation-duration': self.__options.animationDuration[1] + 'ms', 'animation-duration': self.__options.animationDuration[1] + 'ms', 'transition-duration': self.__options.animationDuration[1] + 'ms' }); self._$tooltip // clear both potential open and close tasks .clearQueue() .removeClass('tooltipster-show') // for transitions only .addClass('tooltipster-dying'); if (self.__options.animationDuration[1] > 0) { self._$tooltip.delay(self.__options.animationDuration[1]); } self._$tooltip.queue(finish); } else { self._$tooltip .stop() .fadeOut(self.__options.animationDuration[1], finish); } } } // if the tooltip is already closed, we still need to trigger // the method custom callbacks else { finishCallbacks(); } } return self; }, /** * For internal use by plugins, if needed * * @returns {self} * @protected */ _off: function() { this.__$emitterPrivate.off.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * For internal use by plugins, if needed * * @returns {self} * @protected */ _on: function() { this.__$emitterPrivate.on.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * For internal use by plugins, if needed * * @returns {self} * @protected */ _one: function() { this.__$emitterPrivate.one.apply(this.__$emitterPrivate, Array.prototype.slice.apply(arguments)); return this; }, /** * Opens the tooltip right away. * * @param event * @param callback Will be called when the opening animation is over * @returns {self} * @protected */ _open: function(event, callback) { var self = this; // if the destruction process has not begun and if this was not // triggered by an unwanted emulated click event if (!self.__destroying) { // check that the origin is still in the DOM if ( bodyContains(self._$origin) // if the tooltip is enabled && self.__enabled ) { var ok = true; // if the tooltip is not open yet, we need to call functionBefore. // otherwise we can jst go on if (self.__state == 'closed') { // trigger an event. The event.stop function allows the callback // to prevent the opening of the tooltip self._trigger({ type: 'before', event: event, stop: function() { ok = false; } }); if (ok && self.__options.functionBefore) { // call our custom function before continuing ok = self.__options.functionBefore.call(self, self, { event: event, origin: self._$origin[0] }); } } if (ok !== false) { // if there is some content if (self.__Content !== null) { // save the method callback and cancel close method callbacks if (callback) { self.__callbacks.open.push(callback); } self.__callbacks.close = []; // get rid of any appearance timeouts self.__timeoutsClear(); var extraTime, finish = function() { if (self.__state != 'stable') { self.__stateSet('stable'); } // trigger any open method custom callbacks and reset them $.each(self.__callbacks.open, function(i,c) { c.call(self, self, { origin: self._$origin[0], tooltip: self._$tooltip[0] }); }); self.__callbacks.open = []; }; // if the tooltip is already open if (self.__state !== 'closed') { // the timer (if any) will start (or restart) right now extraTime = 0; // if it was disappearing, cancel that if (self.__state === 'disappearing') { self.__stateSet('appearing'); if (env.hasTransitions) { self._$tooltip .clearQueue() .removeClass('tooltipster-dying') .addClass('tooltipster-show'); if (self.__options.animationDuration[0] > 0) { self._$tooltip.delay(self.__options.animationDuration[0]); } self._$tooltip.queue(finish); } else { // in case the tooltip was currently fading out, bring it back // to life self._$tooltip .stop() .fadeIn(finish); } } // if the tooltip is already open, we still need to trigger the method // custom callback else if (self.__state == 'stable') { finish(); } } // if the tooltip isn't already open, open it else { // a plugin must bind on this and store the tooltip in this._$tooltip self.__stateSet('appearing'); // the timer (if any) will start when the tooltip has fully appeared // after its transition extraTime = self.__options.animationDuration[0]; // insert the content inside the tooltip self.__contentInsert(); // reposition the tooltip and attach to the DOM self.reposition(event, true); // animate in the tooltip. If the display plugin wants no css // animations, it may override the animation option with a // dummy value that will produce no effect if (env.hasTransitions) { // note: there seems to be an issue with start animations which // are randomly not played on fast devices in both Chrome and FF, // couldn't find a way to solve it yet. It seems that applying // the classes before appending to the DOM helps a little, but // it messes up some CSS transitions. The issue almost never // happens when delay[0]==0 though self._$tooltip .addClass('tooltipster-'+ self.__options.animation) .addClass('tooltipster-initial') .css({ '-moz-animation-duration': self.__options.animationDuration[0] + 'ms', '-ms-animation-duration': self.__options.animationDuration[0] + 'ms', '-o-animation-duration': self.__options.animationDuration[0] + 'ms', '-webkit-animation-duration': self.__options.animationDuration[0] + 'ms', 'animation-duration': self.__options.animationDuration[0] + 'ms', 'transition-duration': self.__options.animationDuration[0] + 'ms' }); setTimeout( function() { // a quick hover may have already triggered a mouseleave if (self.__state != 'closed') { self._$tooltip .addClass('tooltipster-show') .removeClass('tooltipster-initial'); if (self.__options.animationDuration[0] > 0) { self._$tooltip.delay(self.__options.animationDuration[0]); } self._$tooltip.queue(finish); } }, 0 ); } else { // old browsers will have to live with this self._$tooltip .css('display', 'none') .fadeIn(self.__options.animationDuration[0], finish); } // checks if the origin is removed while the tooltip is open self.__trackerStart(); // NOTE: the listeners below have a '-triggerClose' namespace // because we'll remove them when the tooltip closes (unlike // the '-triggerOpen' listeners). So some of them are actually // not about close triggers, rather about positioning. $(env.window) // reposition on resize .on('resize.'+ self.__namespace +'-triggerClose', function(e) { var $ae = $(document.activeElement); // reposition only if the resize event was not triggered upon the opening // of a virtual keyboard due to an input field being focused within the tooltip // (otherwise the repositioning would lose the focus) if ( (!$ae.is('input') && !$ae.is('textarea')) || !$.contains(self._$tooltip[0], $ae[0]) ) { self.reposition(e); } }) // same as below for parents .on('scroll.'+ self.__namespace +'-triggerClose', function(e) { self.__scrollHandler(e); }); self.__$originParents = self._$origin.parents(); // scrolling may require the tooltip to be moved or even // repositioned in some cases self.__$originParents.each(function(i, parent) { $(parent).on('scroll.'+ self.__namespace +'-triggerClose', function(e) { self.__scrollHandler(e); }); }); if ( self.__options.triggerClose.mouseleave || (self.__options.triggerClose.touchleave && env.hasTouchCapability) ) { // we use an event to allow users/plugins to control when the mouseleave/touchleave // close triggers will come to action. It allows to have more triggering elements // than just the origin and the tooltip for example, or to cancel/delay the closing, // or to make the tooltip interactive even if it wasn't when it was open, etc. self._on('dismissable', function(event) { if (event.dismissable) { if (event.delay) { timeout = setTimeout(function() { // event.event may be undefined self._close(event.event); }, event.delay); self.__timeouts.close.push(timeout); } else { self._close(event); } } else { clearTimeout(timeout); } }); // now set the listeners that will trigger 'dismissable' events var $elements = self._$origin, eventNamesIn = '', eventNamesOut = '', timeout = null; // if we have to allow interaction, bind on the tooltip too if (self.__options.interactive) { $elements = $elements.add(self._$tooltip); } if (self.__options.triggerClose.mouseleave) { eventNamesIn += 'mouseenter.'+ self.__namespace +'-triggerClose '; eventNamesOut += 'mouseleave.'+ self.__namespace +'-triggerClose '; } if (self.__options.triggerClose.touchleave && env.hasTouchCapability) { eventNamesIn += 'touchstart.'+ self.__namespace +'-triggerClose'; eventNamesOut += 'touchend.'+ self.__namespace +'-triggerClose touchcancel.'+ self.__namespace +'-triggerClose'; } $elements // close after some time spent outside of the elements .on(eventNamesOut, function(event) { // it's ok if the touch gesture ended up to be a swipe, // it's still a "touch leave" situation if ( self._touchIsTouchEvent(event) || !self._touchIsEmulatedEvent(event) ) { var delay = (event.type == 'mouseleave') ? self.__options.delay : self.__options.delayTouch; self._trigger({ delay: delay[1], dismissable: true, event: event, type: 'dismissable' }); } }) // suspend the mouseleave timeout when the pointer comes back // over the elements .on(eventNamesIn, function(event) { // it's also ok if the touch event is a swipe gesture if ( self._touchIsTouchEvent(event) || !self._touchIsEmulatedEvent(event) ) { self._trigger({ dismissable: false, event: event, type: 'dismissable' }); } }); } // close the tooltip when the origin gets a mouse click (common behavior of // native tooltips) if (self.__options.triggerClose.originClick) { self._$origin.on('click.'+ self.__namespace + '-triggerClose', function(event) { // we could actually let a tap trigger this but this feature just // does not make sense on touch devices if ( !self._touchIsTouchEvent(event) && !self._touchIsEmulatedEvent(event) ) { self._close(event); } }); } // set the same bindings for click and touch on the body to close the tooltip if ( self.__options.triggerClose.click || (self.__options.triggerClose.tap && env.hasTouchCapability) ) { // don't set right away since the click/tap event which triggered this method // (if it was a click/tap) is going to bubble up to the body, we don't want it // to close the tooltip immediately after it opened setTimeout(function() { if (self.__state != 'closed') { var eventNames = '', $body = $(env.window.document.body); if (self.__options.triggerClose.click) { eventNames += 'click.'+ self.__namespace +'-triggerClose '; } if (self.__options.triggerClose.tap && env.hasTouchCapability) { eventNames += 'touchend.'+ self.__namespace +'-triggerClose'; } $body.on(eventNames, function(event) { if (self._touchIsMeaningfulEvent(event)) { self._touchRecordEvent(event); if (!self.__options.interactive || !$.contains(self._$tooltip[0], event.target)) { self._close(event); } } }); // needed to detect and ignore swiping if (self.__options.triggerClose.tap && env.hasTouchCapability) { $body.on('touchstart.'+ self.__namespace +'-triggerClose', function(event) { self._touchRecordEvent(event); }); } } }, 0); } self._trigger('ready'); // call our custom callback if (self.__options.functionReady) { self.__options.functionReady.call(self, self, { origin: self._$origin[0], tooltip: self._$tooltip[0] }); } } // if we have a timer set, let the countdown begin if (self.__options.timer > 0) { var timeout = setTimeout(function() { self._close(); }, self.__options.timer + extraTime); self.__timeouts.close.push(timeout); } } } } } return self; }, /** * When using the mouseenter/touchstart open triggers, this function will * schedule the opening of the tooltip after the delay, if there is one * * @param event * @returns {self} * @protected */ _openShortly: function(event) { var self = this, ok = true; if (self.__state != 'stable' && self.__state != 'appearing') { // if a timeout is not already running if (!self.__timeouts.open) { self._trigger({ type: 'start', event: event, stop: function() { ok = false; } }); if (ok) { var delay = (event.type.indexOf('touch') == 0) ? self.__options.delayTouch : self.__options.delay; if (delay[0]) { self.__timeouts.open = setTimeout(function() { self.__timeouts.open = null; // open only if the pointer (mouse or touch) is still over the origin. // The check on the "meaningful event" can only be made here, after some // time has passed (to know if the touch was a swipe or not) if (self.__pointerIsOverOrigin && self._touchIsMeaningfulEvent(event)) { // signal that we go on self._trigger('startend'); self._open(event); } else { // signal that we cancel self._trigger('startcancel'); } }, delay[0]); } else { // signal that we go on self._trigger('startend'); self._open(event); } } } } return self; }, /** * Meant for plugins to get their options * * @param {string} pluginName The name of the plugin that asks for its options * @param {object} defaultOptions The default options of the plugin * @returns {object} The options * @protected */ _optionsExtract: function(pluginName, defaultOptions) { var self = this, options = $.extend(true, {}, defaultOptions); // if the plugin options were isolated in a property named after the // plugin, use them (prevents conflicts with other plugins) var pluginOptions = self.__options[pluginName]; // if not, try to get them as regular options if (!pluginOptions){ pluginOptions = {}; $.each(defaultOptions, function(optionName, value) { var o = self.__options[optionName]; if (o !== undefined) { pluginOptions[optionName] = o; } }); } // let's merge the default options and the ones that were provided. We'd want // to do a deep copy but not let jQuery merge arrays, so we'll do a shallow // extend on two levels, that will be enough if options are not more than 1 // level deep $.each(options, function(optionName, value) { if (pluginOptions[optionName] !== undefined) { if (( typeof value == 'object' && !(value instanceof Array) && value != null ) && ( typeof pluginOptions[optionName] == 'object' && !(pluginOptions[optionName] instanceof Array) && pluginOptions[optionName] != null ) ) { $.extend(options[optionName], pluginOptions[optionName]); } else { options[optionName] = pluginOptions[optionName]; } } }); return options; }, /** * Used at instantiation of the plugin, or afterwards by plugins that activate themselves * on existing instances * * @param {object} pluginName * @returns {self} * @protected */ _plug: function(pluginName) { var plugin = $.tooltipster._plugin(pluginName); if (plugin) { // if there is a constructor for instances if (plugin.instance) { // proxy non-private methods on the instance to allow new instance methods $.tooltipster.__bridge(plugin.instance, this, plugin.name); } } else { throw new Error('The "'+ pluginName +'" plugin is not defined'); } return this; }, /** * This will return true if the event is a mouse event which was * emulated by the browser after a touch event. This allows us to * really dissociate mouse and touch triggers. * * There is a margin of error if a real mouse event is fired right * after (within the delay shown below) a touch event on the same * element, but hopefully it should not happen often. * * @returns {boolean} * @protected */ _touchIsEmulatedEvent: function(event) { var isEmulated = false, now = new Date().getTime(); for (var i = this.__touchEvents.length - 1; i >= 0; i--) { var e = this.__touchEvents[i]; // delay, in milliseconds. It's supposed to be 300ms in // most browsers (350ms on iOS) to allow a double tap but // can be less (check out FastClick for more info) if (now - e.time < 500) { if (e.target === event.target) { isEmulated = true; } } else { break; } } return isEmulated; }, /** * Returns false if the event was an emulated mouse event or * a touch event involved in a swipe gesture. * * @param {object} event * @returns {boolean} * @protected */ _touchIsMeaningfulEvent: function(event) { return ( (this._touchIsTouchEvent(event) && !this._touchSwiped(event.target)) || (!this._touchIsTouchEvent(event) && !this._touchIsEmulatedEvent(event)) ); }, /** * Checks if an event is a touch event * * @param {object} event * @returns {boolean} * @protected */ _touchIsTouchEvent: function(event){ return event.type.indexOf('touch') == 0; }, /** * Store touch events for a while to detect swiping and emulated mouse events * * @param {object} event * @returns {self} * @protected */ _touchRecordEvent: function(event) { if (this._touchIsTouchEvent(event)) { event.time = new Date().getTime(); this.__touchEvents.push(event); } return this; }, /** * Returns true if a swipe happened after the last touchstart event fired on * event.target. * * We need to differentiate a swipe from a tap before we let the event open * or close the tooltip. A swipe is when a touchmove (scroll) event happens * on the body between the touchstart and the touchend events of an element. * * @param {object} target The HTML element that may have triggered the swipe * @returns {boolean} * @protected */ _touchSwiped: function(target) { var swiped = false; for (var i = this.__touchEvents.length - 1; i >= 0; i--) { var e = this.__touchEvents[i]; if (e.type == 'touchmove') { swiped = true; break; } else if ( e.type == 'touchstart' && target === e.target ) { break; } } return swiped; }, /** * Triggers an event on the instance emitters * * @returns {self} * @protected */ _trigger: function() { var args = Array.prototype.slice.apply(arguments); if (typeof args[0] == 'string') { args[0] = { type: args[0] }; } // add properties to the event args[0].instance = this; args[0].origin = this._$origin ? this._$origin[0] : null; args[0].tooltip = this._$tooltip ? this._$tooltip[0] : null; // note: the order of emitters matters this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate, args); $.tooltipster._trigger.apply($.tooltipster, args); this.__$emitterPublic.trigger.apply(this.__$emitterPublic, args); return this; }, /** * Deactivate a plugin on this instance * * @returns {self} * @protected */ _unplug: function(pluginName) { var self = this; // if the plugin has been activated on this instance if (self[pluginName]) { var plugin = $.tooltipster._plugin(pluginName); // if there is a constructor for instances if (plugin.instance) { // unbridge $.each(plugin.instance, function(methodName, fn) { // if the method exists (privates methods do not) and comes indeed from // this plugin (may be missing or come from a conflicting plugin). if ( self[methodName] && self[methodName].bridged === self[pluginName] ) { delete self[methodName]; } }); } // destroy the plugin if (self[pluginName].__destroy) { self[pluginName].__destroy(); } // remove the reference to the plugin instance delete self[pluginName]; } return self; }, /** * @see self::_close * @returns {self} * @public */ close: function(callback) { if (!this.__destroyed) { this._close(null, callback); } else { this.__destroyError(); } return this; }, /** * Sets or gets the content of the tooltip * * @returns {mixed|self} * @public */ content: function(content) { var self = this; // getter method if (content === undefined) { return self.__Content; } // setter method else { if (!self.__destroyed) { // change the content self.__contentSet(content); if (self.__Content !== null) { // update the tooltip if it is open if (self.__state !== 'closed') { // reset the content in the tooltip self.__contentInsert(); // reposition and resize the tooltip self.reposition(); // if we want to play a little animation showing the content changed if (self.__options.updateAnimation) { if (env.hasTransitions) { // keep the reference in the local scope var animation = self.__options.updateAnimation; self._$tooltip.addClass('tooltipster-update-'+ animation); // remove the class after a while. The actual duration of the // update animation may be shorter, it's set in the CSS rules setTimeout(function() { if (self.__state != 'closed') { self._$tooltip.removeClass('tooltipster-update-'+ animation); } }, 1000); } else { self._$tooltip.fadeTo(200, 0.5, function() { if (self.__state != 'closed') { self._$tooltip.fadeTo(200, 1); } }); } } } } else { self._close(); } } else { self.__destroyError(); } return self; } }, /** * Destroys the tooltip * * @returns {self} * @public */ destroy: function() { var self = this; if (!self.__destroyed) { if(self.__state != 'closed'){ // no closing delay self.option('animationDuration', 0) // force closing ._close(null, null, true); } else { // there might be an open timeout still running self.__timeoutsClear(); } // send event self._trigger('destroy'); self.__destroyed = true; self._$origin .removeData(self.__namespace) // remove the open trigger listeners .off('.'+ self.__namespace +'-triggerOpen'); // remove the touch listener $(env.window.document.body).off('.' + self.__namespace +'-triggerOpen'); var ns = self._$origin.data('tooltipster-ns'); // if the origin has been removed from DOM, its data may // well have been destroyed in the process and there would // be nothing to clean up or restore if (ns) { // if there are no more tooltips on this element if (ns.length === 1) { // optional restoration of a title attribute var title = null; if (self.__options.restoration == 'previous') { title = self._$origin.data('tooltipster-initialTitle'); } else if (self.__options.restoration == 'current') { // old school technique to stringify when outerHTML is not supported title = (typeof self.__Content == 'string') ? self.__Content : $('
').append(self.__Content).html(); } if (title) { self._$origin.attr('title', title); } // final cleaning self._$origin.removeClass('tooltipstered'); self._$origin .removeData('tooltipster-ns') .removeData('tooltipster-initialTitle'); } else { // remove the instance namespace from the list of namespaces of // tooltips present on the element ns = $.grep(ns, function(el, i) { return el !== self.__namespace; }); self._$origin.data('tooltipster-ns', ns); } } // last event self._trigger('destroyed'); // unbind private and public event listeners self._off(); self.off(); // remove external references, just in case self.__Content = null; self.__$emitterPrivate = null; self.__$emitterPublic = null; self.__options.parent = null; self._$origin = null; self._$tooltip = null; // make sure the object is no longer referenced in there to prevent // memory leaks $.tooltipster.__instancesLatestArr = $.grep($.tooltipster.__instancesLatestArr, function(el, i) { return self !== el; }); clearInterval(self.__garbageCollector); } else { self.__destroyError(); } // we return the scope rather than true so that the call to // .tooltipster('destroy') actually returns the matched elements // and applies to all of them return self; }, /** * Disables the tooltip * * @returns {self} * @public */ disable: function() { if (!this.__destroyed) { // close first, in case the tooltip would not disappear on // its own (no close trigger) this._close(); this.__enabled = false; return this; } else { this.__destroyError(); } return this; }, /** * Returns the HTML element of the origin * * @returns {self} * @public */ elementOrigin: function() { if (!this.__destroyed) { return this._$origin[0]; } else { this.__destroyError(); } }, /** * Returns the HTML element of the tooltip * * @returns {self} * @public */ elementTooltip: function() { return this._$tooltip ? this._$tooltip[0] : null; }, /** * Enables the tooltip * * @returns {self} * @public */ enable: function() { this.__enabled = true; return this; }, /** * Alias, deprecated in 4.0.0 * * @param {function} callback * @returns {self} * @public */ hide: function(callback) { return this.close(callback); }, /** * Returns the instance * * @returns {self} * @public */ instance: function() { return this; }, /** * For public use only, not to be used by plugins (use ::_off() instead) * * @returns {self} * @public */ off: function() { if (!this.__destroyed) { this.__$emitterPublic.off.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); } return this; }, /** * For public use only, not to be used by plugins (use ::_on() instead) * * @returns {self} * @public */ on: function() { if (!this.__destroyed) { this.__$emitterPublic.on.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); } else { this.__destroyError(); } return this; }, /** * For public use only, not to be used by plugins * * @returns {self} * @public */ one: function() { if (!this.__destroyed) { this.__$emitterPublic.one.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); } else { this.__destroyError(); } return this; }, /** * @see self::_open * @returns {self} * @public */ open: function(callback) { if (!this.__destroyed) { this._open(null, callback); } else { this.__destroyError(); } return this; }, /** * Get or set options. For internal use and advanced users only. * * @param {string} o Option name * @param {mixed} val optional A new value for the option * @return {mixed|self} If val is omitted, the value of the option * is returned, otherwise the instance itself is returned * @public */ option: function(o, val) { // getter if (val === undefined) { return this.__options[o]; } // setter else { if (!this.__destroyed) { // change value this.__options[o] = val; // format this.__optionsFormat(); // re-prepare the triggers if needed if ($.inArray(o, ['trigger', 'triggerClose', 'triggerOpen']) >= 0) { this.__prepareOrigin(); } if (o === 'selfDestruction') { this.__prepareGC(); } } else { this.__destroyError(); } return this; } }, /** * This method is in charge of setting the position and size properties of the tooltip. * All the hard work is delegated to the display plugin. * Note: The tooltip may be detached from the DOM at the moment the method is called * but must be attached by the end of the method call. * * @param {object} event For internal use only. Defined if an event such as * window resizing triggered the repositioning * @param {boolean} tooltipIsDetached For internal use only. Set this to true if you * know that the tooltip not being in the DOM is not an issue (typically when the * tooltip element has just been created but has not been added to the DOM yet). * @returns {self} * @public */ reposition: function(event, tooltipIsDetached) { var self = this; if (!self.__destroyed) { // if the tooltip is still open and the origin is still in the DOM if (self.__state != 'closed' && bodyContains(self._$origin)) { // if the tooltip has not been removed from DOM manually (or if it // has been detached on purpose) if (tooltipIsDetached || bodyContains(self._$tooltip)) { if (!tooltipIsDetached) { // detach in case the tooltip overflows the window and adds // scrollbars to it, so __geometry can be accurate self._$tooltip.detach(); } // refresh the geometry object before passing it as a helper self.__Geometry = self.__geometry(); // let a plugin fo the rest self._trigger({ type: 'reposition', event: event, helper: { geo: self.__Geometry } }); } } } else { self.__destroyError(); } return self; }, /** * Alias, deprecated in 4.0.0 * * @param callback * @returns {self} * @public */ show: function(callback) { return this.open(callback); }, /** * Returns some properties about the instance * * @returns {object} * @public */ status: function() { return { destroyed: this.__destroyed, enabled: this.__enabled, open: this.__state !== 'closed', state: this.__state }; }, /** * For public use only, not to be used by plugins * * @returns {self} * @public */ triggerHandler: function() { if (!this.__destroyed) { this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic, Array.prototype.slice.apply(arguments)); } else { this.__destroyError(); } return this; } }; $.fn.tooltipster = function() { // for using in closures var args = Array.prototype.slice.apply(arguments), // common mistake: an HTML element can't be in several tooltips at the same time contentCloningWarning = 'You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.'; // this happens with $(sel).tooltipster(...) when $(sel) does not match anything if (this.length === 0) { // still chainable return this; } // this happens when calling $(sel).tooltipster('methodName or options') // where $(sel) matches one or more elements else { // method calls if (typeof args[0] === 'string') { var v = '#*$~&'; this.each(function() { // retrieve the namepaces of the tooltip(s) that exist on that element. // We will interact with the first tooltip only. var ns = $(this).data('tooltipster-ns'), // self represents the instance of the first tooltipster plugin // associated to the current HTML object of the loop self = ns ? $(this).data(ns[0]) : null; // if the current element holds a tooltipster instance if (self) { if (typeof self[args[0]] === 'function') { if ( this.length > 1 && args[0] == 'content' && ( args[1] instanceof $ || (typeof args[1] == 'object' && args[1] != null && args[1].tagName) ) && !self.__options.contentCloning && self.__options.debug ) { console.log(contentCloningWarning); } // note : args[1] and args[2] may not be defined var resp = self[args[0]](args[1], args[2]); } else { throw new Error('Unknown method "'+ args[0] +'"'); } // if the function returned anything other than the instance // itself (which implies chaining, except for the `instance` method) if (resp !== self || args[0] === 'instance') { v = resp; // return false to stop .each iteration on the first element // matched by the selector return false; } } else { throw new Error('You called Tooltipster\'s "'+ args[0] +'" method on an uninitialized element'); } }); return (v !== '#*$~&') ? v : this; } // first argument is undefined or an object: the tooltip is initializing else { // reset the array of last initialized objects $.tooltipster.__instancesLatestArr = []; // is there a defined value for the multiple option in the options object ? var multipleIsSet = args[0] && args[0].multiple !== undefined, // if the multiple option is set to true, or if it's not defined but // set to true in the defaults multiple = (multipleIsSet && args[0].multiple) || (!multipleIsSet && defaults.multiple), // same for content contentIsSet = args[0] && args[0].content !== undefined, content = (contentIsSet && args[0].content) || (!contentIsSet && defaults.content), // same for contentCloning contentCloningIsSet = args[0] && args[0].contentCloning !== undefined, contentCloning = (contentCloningIsSet && args[0].contentCloning) || (!contentCloningIsSet && defaults.contentCloning), // same for debug debugIsSet = args[0] && args[0].debug !== undefined, debug = (debugIsSet && args[0].debug) || (!debugIsSet && defaults.debug); if ( this.length > 1 && ( content instanceof $ || (typeof content == 'object' && content != null && content.tagName) ) && !contentCloning && debug ) { console.log(contentCloningWarning); } // create a tooltipster instance for each element if it doesn't // already have one or if the multiple option is set, and attach the // object to it this.each(function() { var go = false, $this = $(this), ns = $this.data('tooltipster-ns'), obj = null; if (!ns) { go = true; } else if (multiple) { go = true; } else if (debug) { console.log('Tooltipster: one or more tooltips are already attached to the element below. Ignoring.'); console.log(this); } if (go) { obj = new $.Tooltipster(this, args[0]); // save the reference of the new instance if (!ns) ns = []; ns.push(obj.__namespace); $this.data('tooltipster-ns', ns); // save the instance itself $this.data(obj.__namespace, obj); // call our constructor custom function. // we do this here and not in ::init() because we wanted // the object to be saved in $this.data before triggering // it if (obj.__options.functionInit) { obj.__options.functionInit.call(obj, obj, { origin: this }); } // and now the event, for the plugins and core emitter obj._trigger('init'); } $.tooltipster.__instancesLatestArr.push(obj); }); return this; } } }; // Utilities /** * A class to check if a tooltip can fit in given dimensions * * @param {object} $tooltip The jQuery wrapped tooltip element, or a clone of it */ function Ruler($tooltip) { // list of instance variables this.$container; this.constraints = null; this.__$tooltip; this.__init($tooltip); } Ruler.prototype = { /** * Move the tooltip into an invisible div that does not allow overflow to make * size tests. Note: the tooltip may or may not be attached to the DOM at the * moment this method is called, it does not matter. * * @param {object} $tooltip The object to test. May be just a clone of the * actual tooltip. * @private */ __init: function($tooltip) { this.__$tooltip = $tooltip; this.__$tooltip .css({ // for some reason we have to specify top and left 0 left: 0, // any overflow will be ignored while measuring overflow: 'hidden', // positions at (0,0) without the div using 100% of the available width position: 'absolute', top: 0 }) // overflow must be auto during the test. We re-set this in case // it were modified by the user .find('.tooltipster-content') .css('overflow', 'auto'); this.$container = $('
') .append(this.__$tooltip) .appendTo(env.window.document.body); }, /** * Force the browser to redraw (re-render) the tooltip immediately. This is required * when you changed some CSS properties and need to make something with it * immediately, without waiting for the browser to redraw at the end of instructions. * * @see http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes * @private */ __forceRedraw: function() { // note: this would work but for Webkit only //this.__$tooltip.close(); //this.__$tooltip[0].offsetHeight; //this.__$tooltip.open(); // works in FF too var $p = this.__$tooltip.parent(); this.__$tooltip.detach(); this.__$tooltip.appendTo($p); }, /** * Set maximum dimensions for the tooltip. A call to ::measure afterwards * will tell us if the content overflows or if it's ok * * @param {int} width * @param {int} height * @return {Ruler} * @public */ constrain: function(width, height) { this.constraints = { width: width, height: height }; this.__$tooltip.css({ // we disable display:flex, otherwise the content would overflow without // creating horizontal scrolling (which we need to detect). display: 'block', // reset any previous height height: '', // we'll check if horizontal scrolling occurs overflow: 'auto', // we'll set the width and see what height is generated and if there // is horizontal overflow width: width }); return this; }, /** * Reset the tooltip content overflow and remove the test container * * @returns {Ruler} * @public */ destroy: function() { // in case the element was not a clone this.__$tooltip .detach() .find('.tooltipster-content') .css({ // reset to CSS value display: '', overflow: '' }); this.$container.remove(); }, /** * Removes any constraints * * @returns {Ruler} * @public */ free: function() { this.constraints = null; // reset to natural size this.__$tooltip.css({ display: '', height: '', overflow: 'visible', width: '' }); return this; }, /** * Returns the size of the tooltip. When constraints are applied, also returns * whether the tooltip fits in the provided dimensions. * The idea is to see if the new height is small enough and if the content does * not overflow horizontally. * * @param {int} width * @param {int} height * @returns {object} An object with a bool `fits` property and a `size` property * @public */ measure: function() { this.__forceRedraw(); var tooltipBcr = this.__$tooltip[0].getBoundingClientRect(), result = { size: { // bcr.width/height are not defined in IE8- but in this // case, bcr.right/bottom will have the same value // except in iOS 8+ where tooltipBcr.bottom/right are wrong // after scrolling for reasons yet to be determined. // tooltipBcr.top/left might not be 0, see issue #514 height: tooltipBcr.height || (tooltipBcr.bottom - tooltipBcr.top), width: tooltipBcr.width || (tooltipBcr.right - tooltipBcr.left) }}; if (this.constraints) { // note: we used to use offsetWidth instead of boundingRectClient but // it returned rounded values, causing issues with sub-pixel layouts. // note2: noticed that the bcrWidth of text content of a div was once // greater than the bcrWidth of its container by 1px, causing the final // tooltip box to be too small for its content. However, evaluating // their widths one against the other (below) surprisingly returned // equality. Happened only once in Chrome 48, was not able to reproduce // => just having fun with float position values... var $content = this.__$tooltip.find('.tooltipster-content'), height = this.__$tooltip.outerHeight(), contentBcr = $content[0].getBoundingClientRect(), fits = { height: height <= this.constraints.height, width: ( // this condition accounts for min-width property that // may apply tooltipBcr.width <= this.constraints.width // the -1 is here because scrollWidth actually returns // a rounded value, and may be greater than bcr.width if // it was rounded up. This may cause an issue for contents // which actually really overflow by 1px or so, but that // should be rare. Not sure how to solve this efficiently. // See http://blogs.msdn.com/b/ie/archive/2012/02/17/sub-pixel-rendering-and-the-css-object-model.aspx && contentBcr.width >= $content[0].scrollWidth - 1 ) }; result.fits = fits.height && fits.width; } // old versions of IE get the width wrong for some reason and it causes // the text to be broken to a new line, so we round it up. If the width // is the width of the screen though, we can assume it is accurate. if ( env.IE && env.IE <= 11 && result.size.width !== env.window.document.documentElement.clientWidth ) { result.size.width = Math.ceil(result.size.width) + 1; } return result; } }; // quick & dirty compare function, not bijective nor multidimensional function areEqual(a,b) { var same = true; $.each(a, function(i, _) { if (b[i] === undefined || a[i] !== b[i]) { same = false; return false; } }); return same; } /** * A fast function to check if an element is still in the DOM. It * tries to use an id as ids are indexed by the browser, or falls * back to jQuery's `contains` method. May fail if two elements * have the same id, but so be it * * @param {object} $obj A jQuery-wrapped HTML element * @return {boolean} */ function bodyContains($obj) { var id = $obj.attr('id'), el = id ? env.window.document.getElementById(id) : null; // must also check that the element with the id is the one we want return el ? el === $obj[0] : $.contains(env.window.document.body, $obj[0]); } // detect IE versions for dirty fixes var uA = navigator.userAgent.toLowerCase(); if (uA.indexOf('msie') != -1) env.IE = parseInt(uA.split('msie')[1]); else if (uA.toLowerCase().indexOf('trident') !== -1 && uA.indexOf(' rv:11') !== -1) env.IE = 11; else if (uA.toLowerCase().indexOf('edge/') != -1) env.IE = parseInt(uA.toLowerCase().split('edge/')[1]); // detecting support for CSS transitions function transitionSupport() { // env.window is not defined yet when this is called if (!win) return false; var b = win.document.body || win.document.documentElement, s = b.style, p = 'transition', v = ['Moz', 'Webkit', 'Khtml', 'O', 'ms']; if (typeof s[p] == 'string') { return true; } p = p.charAt(0).toUpperCase() + p.substr(1); for (var i=0; i' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' ); // hide arrow if asked if (!this.__options.arrow) { $html .find('.tooltipster-box') .css('margin', 0) .end() .find('.tooltipster-arrow') .hide(); } // apply min/max width if asked if (this.__options.minWidth) { $html.css('min-width', this.__options.minWidth + 'px'); } if (this.__options.maxWidth) { $html.css('max-width', this.__options.maxWidth + 'px'); } this.__instance._$tooltip = $html; // tell the instance that the tooltip element has been created this.__instance._trigger('created'); }, /** * Used when the plugin is to be unplugged * * @private */ __destroy: function() { this.__instance._off('.'+ self.__namespace); }, /** * (Re)compute this.__options from the options declared to the instance * * @private */ __optionsFormat: function() { var self = this; // get the options self.__options = self.__instance._optionsExtract(pluginName, self.__defaults()); // for backward compatibility, deprecated in v4.0.0 if (self.__options.position) { self.__options.side = self.__options.position; } // options formatting // format distance as a four-cell array if it ain't one yet and then make // it an object with top/bottom/left/right properties if (typeof self.__options.distance != 'object') { self.__options.distance = [self.__options.distance]; } if (self.__options.distance.length < 4) { if (self.__options.distance[1] === undefined) self.__options.distance[1] = self.__options.distance[0]; if (self.__options.distance[2] === undefined) self.__options.distance[2] = self.__options.distance[0]; if (self.__options.distance[3] === undefined) self.__options.distance[3] = self.__options.distance[1]; self.__options.distance = { top: self.__options.distance[0], right: self.__options.distance[1], bottom: self.__options.distance[2], left: self.__options.distance[3] }; } // let's transform: // 'top' into ['top', 'bottom', 'right', 'left'] // 'right' into ['right', 'left', 'top', 'bottom'] // 'bottom' into ['bottom', 'top', 'right', 'left'] // 'left' into ['left', 'right', 'top', 'bottom'] if (typeof self.__options.side == 'string') { var opposites = { 'top': 'bottom', 'right': 'left', 'bottom': 'top', 'left': 'right' }; self.__options.side = [self.__options.side, opposites[self.__options.side]]; if (self.__options.side[0] == 'left' || self.__options.side[0] == 'right') { self.__options.side.push('top', 'bottom'); } else { self.__options.side.push('right', 'left'); } } // misc // disable the arrow in IE6 unless the arrow option was explicitly set to true if ( $.tooltipster._env.IE === 6 && self.__options.arrow !== true ) { self.__options.arrow = false; } }, /** * This method must compute and set the positioning properties of the * tooltip (left, top, width, height, etc.). It must also make sure the * tooltip is eventually appended to its parent (since the element may be * detached from the DOM at the moment the method is called). * * We'll evaluate positioning scenarios to find which side can contain the * tooltip in the best way. We'll consider things relatively to the window * (unless the user asks not to), then to the document (if need be, or if the * user explicitly requires the tests to run on the document). For each * scenario, measures are taken, allowing us to know how well the tooltip * is going to fit. After that, a sorting function will let us know what * the best scenario is (we also allow the user to choose his favorite * scenario by using an event). * * @param {object} helper An object that contains variables that plugin * creators may find useful (see below) * @param {object} helper.geo An object with many layout properties * about objects of interest (window, document, origin). This should help * plugin users compute the optimal position of the tooltip * @private */ __reposition: function(event, helper) { var self = this, finalResult, // to know where to put the tooltip, we need to know on which point // of the x or y axis we should center it. That coordinate is the target targets = self.__targetFind(helper), testResults = []; // make sure the tooltip is detached while we make tests on a clone self.__instance._$tooltip.detach(); // we could actually provide the original element to the Ruler and // not a clone, but it just feels right to keep it out of the // machinery. var $clone = self.__instance._$tooltip.clone(), // start position tests session ruler = $.tooltipster._getRuler($clone), satisfied = false, animation = self.__instance.option('animation'); // an animation class could contain properties that distort the size if (animation) { $clone.removeClass('tooltipster-'+ animation); } // start evaluating scenarios $.each(['window', 'document'], function(i, container) { var takeTest = null; // let the user decide to keep on testing or not self.__instance._trigger({ container: container, helper: helper, satisfied: satisfied, takeTest: function(bool) { takeTest = bool; }, results: testResults, type: 'positionTest' }); if ( takeTest == true || ( takeTest != false && satisfied == false // skip the window scenarios if asked. If they are reintegrated by // the callback of the positionTest event, they will have to be // excluded using the callback of positionTested && (container != 'window' || self.__options.viewportAware) ) ) { // for each allowed side for (var i=0; i < self.__options.side.length; i++) { var distance = { horizontal: 0, vertical: 0 }, side = self.__options.side[i]; if (side == 'top' || side == 'bottom') { distance.vertical = self.__options.distance[side]; } else { distance.horizontal = self.__options.distance[side]; } // this may have an effect on the size of the tooltip if there are css // rules for the arrow or something else self.__sideChange($clone, side); $.each(['natural', 'constrained'], function(i, mode) { takeTest = null; // emit an event on the instance self.__instance._trigger({ container: container, event: event, helper: helper, mode: mode, results: testResults, satisfied: satisfied, side: side, takeTest: function(bool) { takeTest = bool; }, type: 'positionTest' }); if ( takeTest == true || ( takeTest != false && satisfied == false ) ) { var testResult = { container: container, // we let the distance as an object here, it can make things a little easier // during the user's calculations at positionTest/positionTested distance: distance, // whether the tooltip can fit in the size of the viewport (does not mean // that we'll be able to make it initially entirely visible, see 'whole') fits: null, mode: mode, outerSize: null, side: side, size: null, target: targets[side], // check if the origin has enough surface on screen for the tooltip to // aim at it without overflowing the viewport (this is due to the thickness // of the arrow represented by the minIntersection length). // If not, the tooltip will have to be partly or entirely off screen in // order to stay docked to the origin. This value will stay null when the // container is the document, as it is not relevant whole: null }; // get the size of the tooltip with or without size constraints var rulerConfigured = (mode == 'natural') ? ruler.free() : ruler.constrain( helper.geo.available[container][side].width - distance.horizontal, helper.geo.available[container][side].height - distance.vertical ), rulerResults = rulerConfigured.measure(); testResult.size = rulerResults.size; testResult.outerSize = { height: rulerResults.size.height + distance.vertical, width: rulerResults.size.width + distance.horizontal }; if (mode == 'natural') { if( helper.geo.available[container][side].width >= testResult.outerSize.width && helper.geo.available[container][side].height >= testResult.outerSize.height ) { testResult.fits = true; } else { testResult.fits = false; } } else { testResult.fits = rulerResults.fits; } if (container == 'window') { if (!testResult.fits) { testResult.whole = false; } else { if (side == 'top' || side == 'bottom') { testResult.whole = ( helper.geo.origin.windowOffset.right >= self.__options.minIntersection && helper.geo.window.size.width - helper.geo.origin.windowOffset.left >= self.__options.minIntersection ); } else { testResult.whole = ( helper.geo.origin.windowOffset.bottom >= self.__options.minIntersection && helper.geo.window.size.height - helper.geo.origin.windowOffset.top >= self.__options.minIntersection ); } } } testResults.push(testResult); // we don't need to compute more positions if we have one fully on screen if (testResult.whole) { satisfied = true; } else { // don't run the constrained test unless the natural width was greater // than the available width, otherwise it's pointless as we know it // wouldn't fit either if ( testResult.mode == 'natural' && ( testResult.fits || testResult.size.width <= helper.geo.available[container][side].width ) ) { return false; } } } }); } } }); // the user may eliminate the unwanted scenarios from testResults, but he's // not supposed to alter them at this point. functionPosition and the // position event serve that purpose. self.__instance._trigger({ edit: function(r) { testResults = r; }, event: event, helper: helper, results: testResults, type: 'positionTested' }); /** * Sort the scenarios to find the favorite one. * * The favorite scenario is when we can fully display the tooltip on screen, * even if it means that the middle of the tooltip is no longer centered on * the middle of the origin (when the origin is near the edge of the screen * or even partly off screen). We want the tooltip on the preferred side, * even if it means that we have to use a constrained size rather than a * natural one (as long as it fits). When the origin is off screen at the top * the tooltip will be positioned at the bottom (if allowed), if the origin * is off screen on the right, it will be positioned on the left, etc. * If there are no scenarios where the tooltip can fit on screen, or if the * user does not want the tooltip to fit on screen (viewportAware == false), * we fall back to the scenarios relative to the document. * * When the tooltip is bigger than the viewport in either dimension, we stop * looking at the window scenarios and consider the document scenarios only, * with the same logic to find on which side it would fit best. * * If the tooltip cannot fit the document on any side, we force it at the * bottom, so at least the user can scroll to see it. */ testResults.sort(function(a, b) { // best if it's whole (the tooltip fits and adapts to the viewport) if (a.whole && !b.whole) { return -1; } else if (!a.whole && b.whole) { return 1; } else if (a.whole && b.whole) { var ai = self.__options.side.indexOf(a.side), bi = self.__options.side.indexOf(b.side); // use the user's sides fallback array if (ai < bi) { return -1; } else if (ai > bi) { return 1; } else { // will be used if the user forced the tests to continue return a.mode == 'natural' ? -1 : 1; } } else { // better if it fits if (a.fits && !b.fits) { return -1; } else if (!a.fits && b.fits) { return 1; } else if (a.fits && b.fits) { var ai = self.__options.side.indexOf(a.side), bi = self.__options.side.indexOf(b.side); // use the user's sides fallback array if (ai < bi) { return -1; } else if (ai > bi) { return 1; } else { // will be used if the user forced the tests to continue return a.mode == 'natural' ? -1 : 1; } } else { // if everything failed, this will give a preference to the case where // the tooltip overflows the document at the bottom if ( a.container == 'document' && a.side == 'bottom' && a.mode == 'natural' ) { return -1; } else { return 1; } } } }); finalResult = testResults[0]; // now let's find the coordinates of the tooltip relatively to the window finalResult.coord = {}; switch (finalResult.side) { case 'left': case 'right': finalResult.coord.top = Math.floor(finalResult.target - finalResult.size.height / 2); break; case 'bottom': case 'top': finalResult.coord.left = Math.floor(finalResult.target - finalResult.size.width / 2); break; } switch (finalResult.side) { case 'left': finalResult.coord.left = helper.geo.origin.windowOffset.left - finalResult.outerSize.width; break; case 'right': finalResult.coord.left = helper.geo.origin.windowOffset.right + finalResult.distance.horizontal; break; case 'top': finalResult.coord.top = helper.geo.origin.windowOffset.top - finalResult.outerSize.height; break; case 'bottom': finalResult.coord.top = helper.geo.origin.windowOffset.bottom + finalResult.distance.vertical; break; } // if the tooltip can potentially be contained within the viewport dimensions // and that we are asked to make it fit on screen if (finalResult.container == 'window') { // if the tooltip overflows the viewport, we'll move it accordingly (then it will // not be centered on the middle of the origin anymore). We only move horizontally // for top and bottom tooltips and vice versa. if (finalResult.side == 'top' || finalResult.side == 'bottom') { // if there is an overflow on the left if (finalResult.coord.left < 0) { // prevent the overflow unless the origin itself gets off screen (minus the // margin needed to keep the arrow pointing at the target) if (helper.geo.origin.windowOffset.right - this.__options.minIntersection >= 0) { finalResult.coord.left = 0; } else { finalResult.coord.left = helper.geo.origin.windowOffset.right - this.__options.minIntersection - 1; } } // or an overflow on the right else if (finalResult.coord.left > helper.geo.window.size.width - finalResult.size.width) { if (helper.geo.origin.windowOffset.left + this.__options.minIntersection <= helper.geo.window.size.width) { finalResult.coord.left = helper.geo.window.size.width - finalResult.size.width; } else { finalResult.coord.left = helper.geo.origin.windowOffset.left + this.__options.minIntersection + 1 - finalResult.size.width; } } } else { // overflow at the top if (finalResult.coord.top < 0) { if (helper.geo.origin.windowOffset.bottom - this.__options.minIntersection >= 0) { finalResult.coord.top = 0; } else { finalResult.coord.top = helper.geo.origin.windowOffset.bottom - this.__options.minIntersection - 1; } } // or at the bottom else if (finalResult.coord.top > helper.geo.window.size.height - finalResult.size.height) { if (helper.geo.origin.windowOffset.top + this.__options.minIntersection <= helper.geo.window.size.height) { finalResult.coord.top = helper.geo.window.size.height - finalResult.size.height; } else { finalResult.coord.top = helper.geo.origin.windowOffset.top + this.__options.minIntersection + 1 - finalResult.size.height; } } } } else { // there might be overflow here too but it's easier to handle. If there has // to be an overflow, we'll make sure it's on the right side of the screen // (because the browser will extend the document size if there is an overflow // on the right, but not on the left). The sort function above has already // made sure that a bottom document overflow is preferred to a top overflow, // so we don't have to care about it. // if there is an overflow on the right if (finalResult.coord.left > helper.geo.window.size.width - finalResult.size.width) { // this may actually create on overflow on the left but we'll fix it in a sec finalResult.coord.left = helper.geo.window.size.width - finalResult.size.width; } // if there is an overflow on the left if (finalResult.coord.left < 0) { // don't care if it overflows the right after that, we made our best finalResult.coord.left = 0; } } // submit the positioning proposal to the user function which may choose to change // the side, size and/or the coordinates // first, set the rules that corresponds to the proposed side: it may change // the size of the tooltip, and the custom functionPosition may want to detect the // size of something before making a decision. So let's make things easier for the // implementor self.__sideChange($clone, finalResult.side); // add some variables to the helper helper.tooltipClone = $clone[0]; helper.tooltipParent = self.__instance.option('parent').parent[0]; // move informative values to the helper helper.mode = finalResult.mode; helper.whole = finalResult.whole; // add some variables to the helper for the functionPosition callback (these // will also be added to the event fired by self.__instance._trigger but that's // ok, we're just being consistent) helper.origin = self.__instance._$origin[0]; helper.tooltip = self.__instance._$tooltip[0]; // leave only the actionable values in there for functionPosition delete finalResult.container; delete finalResult.fits; delete finalResult.mode; delete finalResult.outerSize; delete finalResult.whole; // keep only the distance on the relevant side, for clarity finalResult.distance = finalResult.distance.horizontal || finalResult.distance.vertical; // beginners may not be comfortable with the concept of editing the object // passed by reference, so we provide an edit function and pass a clone var finalResultClone = $.extend(true, {}, finalResult); // emit an event on the instance self.__instance._trigger({ edit: function(result) { finalResult = result; }, event: event, helper: helper, position: finalResultClone, type: 'position' }); if (self.__options.functionPosition) { var result = self.__options.functionPosition.call(self, self.__instance, helper, finalResultClone); if (result) finalResult = result; } // end the positioning tests session (the user might have had a // use for it during the position event, now it's over) ruler.destroy(); // compute the position of the target relatively to the tooltip root // element so we can place the arrow and make the needed adjustments var arrowCoord, maxVal; if (finalResult.side == 'top' || finalResult.side == 'bottom') { arrowCoord = { prop: 'left', val: finalResult.target - finalResult.coord.left }; maxVal = finalResult.size.width - this.__options.minIntersection; } else { arrowCoord = { prop: 'top', val: finalResult.target - finalResult.coord.top }; maxVal = finalResult.size.height - this.__options.minIntersection; } // cannot lie beyond the boundaries of the tooltip, minus the // arrow margin if (arrowCoord.val < this.__options.minIntersection) { arrowCoord.val = this.__options.minIntersection; } else if (arrowCoord.val > maxVal) { arrowCoord.val = maxVal; } var originParentOffset; // let's convert the window-relative coordinates into coordinates relative to the // future positioned parent that the tooltip will be appended to if (helper.geo.origin.fixedLineage) { // same as windowOffset when the position is fixed originParentOffset = helper.geo.origin.windowOffset; } else { // this assumes that the parent of the tooltip is located at // (0, 0) in the document, typically like when the parent is // . // If we ever allow other types of parent, .tooltipster-ruler // will have to be appended to the parent to inherit css style // values that affect the display of the text and such. originParentOffset = { left: helper.geo.origin.windowOffset.left + helper.geo.window.scroll.left, top: helper.geo.origin.windowOffset.top + helper.geo.window.scroll.top }; } finalResult.coord = { left: originParentOffset.left + (finalResult.coord.left - helper.geo.origin.windowOffset.left), top: originParentOffset.top + (finalResult.coord.top - helper.geo.origin.windowOffset.top) }; // set position values on the original tooltip element self.__sideChange(self.__instance._$tooltip, finalResult.side); if (helper.geo.origin.fixedLineage) { self.__instance._$tooltip .css('position', 'fixed'); } else { // CSS default self.__instance._$tooltip .css('position', ''); } self.__instance._$tooltip .css({ left: finalResult.coord.left, top: finalResult.coord.top, // we need to set a size even if the tooltip is in its natural size // because when the tooltip is positioned beyond the width of the body // (which is by default the width of the window; it will happen when // you scroll the window horizontally to get to the origin), its text // content will otherwise break lines at each word to keep up with the // body overflow strategy. height: finalResult.size.height, width: finalResult.size.width }) .find('.tooltipster-arrow') .css({ 'left': '', 'top': '' }) .css(arrowCoord.prop, arrowCoord.val); // append the tooltip HTML element to its parent self.__instance._$tooltip.appendTo(self.__instance.option('parent')); self.__instance._trigger({ type: 'repositioned', event: event, position: finalResult }); }, /** * Make whatever modifications are needed when the side is changed. This has * been made an independant method for easy inheritance in custom plugins based * on this default plugin. * * @param {object} $obj * @param {string} side * @private */ __sideChange: function($obj, side) { $obj .removeClass('tooltipster-bottom') .removeClass('tooltipster-left') .removeClass('tooltipster-right') .removeClass('tooltipster-top') .addClass('tooltipster-'+ side); }, /** * Returns the target that the tooltip should aim at for a given side. * The calculated value is a distance from the edge of the window * (left edge for top/bottom sides, top edge for left/right side). The * tooltip will be centered on that position and the arrow will be * positioned there (as much as possible). * * @param {object} helper * @return {integer} * @private */ __targetFind: function(helper) { var target = {}, rects = this.__instance._$origin[0].getClientRects(); // these lines fix a Chrome bug (issue #491) if (rects.length > 1) { var opacity = this.__instance._$origin.css('opacity'); if(opacity == 1) { this.__instance._$origin.css('opacity', 0.99); rects = this.__instance._$origin[0].getClientRects(); this.__instance._$origin.css('opacity', 1); } } // by default, the target will be the middle of the origin if (rects.length < 2) { target.top = Math.floor(helper.geo.origin.windowOffset.left + (helper.geo.origin.size.width / 2)); target.bottom = target.top; target.left = Math.floor(helper.geo.origin.windowOffset.top + (helper.geo.origin.size.height / 2)); target.right = target.left; } // if multiple client rects exist, the element may be text split // up into multiple lines and the middle of the origin may not be // best option anymore. We need to choose the best target client rect else { // top: the first var targetRect = rects[0]; target.top = Math.floor(targetRect.left + (targetRect.right - targetRect.left) / 2); // right: the middle line, rounded down in case there is an even // number of lines (looks more centered => check out the // demo with 4 split lines) if (rects.length > 2) { targetRect = rects[Math.ceil(rects.length / 2) - 1]; } else { targetRect = rects[0]; } target.right = Math.floor(targetRect.top + (targetRect.bottom - targetRect.top) / 2); // bottom: the last targetRect = rects[rects.length - 1]; target.bottom = Math.floor(targetRect.left + (targetRect.right - targetRect.left) / 2); // left: the middle line, rounded up if (rects.length > 2) { targetRect = rects[Math.ceil((rects.length + 1) / 2) - 1]; } else { targetRect = rects[rects.length - 1]; } target.left = Math.floor(targetRect.top + (targetRect.bottom - targetRect.top) / 2); } return target; } } }); /* a build task will add "return $;" here */ return $; }));