diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 19:47:39 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 19:47:39 +0000 |
commit | 8d13bdc6cac0e20c43c6f909fc0208774b9c5c84 (patch) | |
tree | 5fd46925c6b4a881c9208772ed8e5cc0588bc164 /src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js | |
parent | Initial commit. (diff) | |
download | privacybadger-8d13bdc6cac0e20c43c6f909fc0208774b9c5c84.tar.xz privacybadger-8d13bdc6cac0e20c43c6f909fc0208774b9c5c84.zip |
Adding upstream version 2020.10.7.upstream/2020.10.7upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js')
-rw-r--r-- | src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js | 4273 |
1 files changed, 4273 insertions, 0 deletions
diff --git a/src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js b/src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js new file mode 100644 index 0000000..e2250a6 --- /dev/null +++ b/src/lib/vendor/tooltipster-4.2.6/tooltipster.bundle.js @@ -0,0 +1,4273 @@ +/**
+ * 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.6', + 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 : + $('<div></div>').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 = $('<div class="tooltipster-ruler"></div>') + .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<v.length; i++) { + if (typeof s[v[i] + p] == 'string') { return true; } + } + return false; +} + +// we'll return jQuery for plugins not to have to declare it as a dependency, +// but it's done by a build task since it should be included only once at the +// end when we concatenate the main file with a plugin
+// sideTip is Tooltipster's default plugin. +// This file will be UMDified by a build task. + +var pluginName = 'tooltipster.sideTip'; + +$.tooltipster._plugin({ + name: pluginName, + instance: { + /** + * Defaults are provided as a function for an easy override by inheritance + * + * @return {object} An object with the defaults options + * @private + */ + __defaults: function() { + + return { + // if the tooltip should display an arrow that points to the origin + arrow: true, + // the distance in pixels between the tooltip and the origin + distance: 6, + // allows to easily change the position of the tooltip + functionPosition: null, + maxWidth: null, + // used to accomodate the arrow of tooltip if there is one. + // First to make sure that the arrow target is not too close + // to the edge of the tooltip, so the arrow does not overflow + // the tooltip. Secondly when we reposition the tooltip to + // make sure that it's positioned in such a way that the arrow is + // still pointing at the target (and not a few pixels beyond it). + // It should be equal to or greater than half the width of + // the arrow (by width we mean the size of the side which touches + // the side of the tooltip). + minIntersection: 16, + minWidth: 0, + // deprecated in 4.0.0. Listed for _optionsExtract to pick it up + position: null, + side: 'top', + // set to false to position the tooltip relatively to the document rather + // than the window when we open it + viewportAware: true + }; + }, + + /** + * Run once: at instantiation of the plugin + * + * @param {object} instance The tooltipster object that instantiated this plugin + * @private + */ + __init: function(instance) { + + var self = this; + + // list of instance variables + + self.__instance = instance; + self.__namespace = 'tooltipster-sideTip-'+ Math.round(Math.random()*1000000); + self.__previousState = 'closed'; + self.__options; + + // initial formatting + self.__optionsFormat(); + + self.__instance._on('state.'+ self.__namespace, function(event) { + + if (event.state == 'closed') { + self.__close(); + } + else if (event.state == 'appearing' && self.__previousState == 'closed') { + self.__create(); + } + + self.__previousState = event.state; + }); + + // reformat every time the options are changed + self.__instance._on('options.'+ self.__namespace, function() { + self.__optionsFormat(); + }); + + self.__instance._on('reposition.'+ self.__namespace, function(e) { + self.__reposition(e.event, e.helper); + }); + }, + + /** + * Called when the tooltip has closed + * + * @private + */ + __close: function() { + + // detach our content object first, so the next jQuery's remove() + // call does not unbind its event handlers + if (this.__instance.content() instanceof $) { + this.__instance.content().detach(); + } + + // remove the tooltip from the DOM + this.__instance._$tooltip.remove(); + this.__instance._$tooltip = null; + }, + + /** + * Creates the HTML element of the tooltip. + * + * @private + */ + __create: function() { + + // note: we wrap with a .tooltipster-box div to be able to set a margin on it + // (.tooltipster-base must not have one) + var $html = $( + '<div class="tooltipster-base tooltipster-sidetip">' + + '<div class="tooltipster-box">' + + '<div class="tooltipster-content"></div>' + + '</div>' + + '<div class="tooltipster-arrow">' + + '<div class="tooltipster-arrow-uncropped">' + + '<div class="tooltipster-arrow-border"></div>' + + '<div class="tooltipster-arrow-background"></div>' + + '</div>' + + '</div>' + + '</div>' + ); + + // 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 + // <body>. + // 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 $; + +})); |