qtip2
Version:
Introducing... qTip2. The second generation of the advanced qTip plugin for the ever popular jQuery framework.
637 lines (524 loc) • 19.1 kB
JavaScript
var TIP,
createVML,
SCALE,
PIXEL_RATIO,
BACKING_STORE_RATIO,
// Common CSS strings
MARGIN = 'margin',
BORDER = 'border',
COLOR = 'color',
BG_COLOR = 'background-color',
TRANSPARENT = 'transparent',
IMPORTANT = ' !important',
// Check if the browser supports <canvas/> elements
HASCANVAS = !!document.createElement('canvas').getContext,
// Invalid colour values used in parseColours()
INVALID = /rgba?\(0, 0, 0(, 0)?\)|transparent|#123456/i;
// Camel-case method, taken from jQuery source
// http://code.jquery.com/jquery-1.8.0.js
function camel(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
/*
* Modified from Modernizr's testPropsAll()
* http://modernizr.com/downloads/modernizr-latest.js
*/
var cssProps = {}, cssPrefixes = ['Webkit', 'O', 'Moz', 'ms'];
function vendorCss(elem, prop) {
var ucProp = prop.charAt(0).toUpperCase() + prop.slice(1),
props = (prop + ' ' + cssPrefixes.join(ucProp + ' ') + ucProp).split(' '),
cur, val, i = 0;
// If the property has already been mapped...
if(cssProps[prop]) { return elem.css(cssProps[prop]); }
while(cur = props[i++]) {
if((val = elem.css(cur)) !== undefined) {
cssProps[prop] = cur;
return val;
}
}
}
// Parse a given elements CSS property into an int
function intCss(elem, prop) {
return Math.ceil(parseFloat(vendorCss(elem, prop)));
}
// VML creation (for IE only)
if(!HASCANVAS) {
createVML = function(tag, props, style) {
return '<qtipvml:'+tag+' xmlns="urn:schemas-microsoft.com:vml" class="qtip-vml" '+(props||'')+
' style="behavior: url(#default#VML); '+(style||'')+ '" />';
};
}
// Canvas only definitions
else {
PIXEL_RATIO = window.devicePixelRatio || 1;
BACKING_STORE_RATIO = (function() {
var context = document.createElement('canvas').getContext('2d');
return context.backingStorePixelRatio || context.webkitBackingStorePixelRatio || context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio || context.oBackingStorePixelRatio || 1;
})();
SCALE = PIXEL_RATIO / BACKING_STORE_RATIO;
}
function Tip(qtip, options) {
this._ns = 'tip';
this.options = options;
this.offset = options.offset;
this.size = [ options.width, options.height ];
// Initialize
this.qtip = qtip;
this.init(qtip);
}
$.extend(Tip.prototype, {
init: function(qtip) {
var context, tip;
// Create tip element and prepend to the tooltip
tip = this.element = qtip.elements.tip = $('<div />', { 'class': NAMESPACE+'-tip' }).prependTo(qtip.tooltip);
// Create tip drawing element(s)
if(HASCANVAS) {
// save() as soon as we create the canvas element so FF2 doesn't bork on our first restore()!
context = $('<canvas />').appendTo(this.element)[0].getContext('2d');
// Setup constant parameters
context.lineJoin = 'miter';
context.miterLimit = 100000;
context.save();
}
else {
context = createVML('shape', 'coordorigin="0,0"', 'position:absolute;');
this.element.html(context + context);
// Prevent mousing down on the tip since it causes problems with .live() handling in IE due to VML
qtip._bind( $('*', tip).add(tip), ['click', 'mousedown'], function(event) { event.stopPropagation(); }, this._ns);
}
// Bind update events
qtip._bind(qtip.tooltip, 'tooltipmove', this.reposition, this._ns, this);
// Create it
this.create();
},
_swapDimensions: function() {
this.size[0] = this.options.height;
this.size[1] = this.options.width;
},
_resetDimensions: function() {
this.size[0] = this.options.width;
this.size[1] = this.options.height;
},
_useTitle: function(corner) {
var titlebar = this.qtip.elements.titlebar;
return titlebar && (
corner.y === TOP || corner.y === CENTER && this.element.position().top + this.size[1] / 2 + this.options.offset < titlebar.outerHeight(TRUE)
);
},
_parseCorner: function(corner) {
var my = this.qtip.options.position.my;
// Detect corner and mimic properties
if(corner === FALSE || my === FALSE) {
corner = FALSE;
}
else if(corner === TRUE) {
corner = new CORNER( my.string() );
}
else if(!corner.string) {
corner = new CORNER(corner);
corner.fixed = TRUE;
}
return corner;
},
_parseWidth: function(corner, side, use) {
var elements = this.qtip.elements,
prop = BORDER + camel(side) + 'Width';
return (use ? intCss(use, prop) :
intCss(elements.content, prop) ||
intCss(this._useTitle(corner) && elements.titlebar || elements.content, prop) ||
intCss(elements.tooltip, prop)
) || 0;
},
_parseRadius: function(corner) {
var elements = this.qtip.elements,
prop = BORDER + camel(corner.y) + camel(corner.x) + 'Radius';
return BROWSER.ie < 9 ? 0 :
intCss(this._useTitle(corner) && elements.titlebar || elements.content, prop) ||
intCss(elements.tooltip, prop) || 0;
},
_invalidColour: function(elem, prop, compare) {
var val = elem.css(prop);
return !val || compare && val === elem.css(compare) || INVALID.test(val) ? FALSE : val;
},
_parseColours: function(corner) {
var elements = this.qtip.elements,
tip = this.element.css('cssText', ''),
borderSide = BORDER + camel(corner[ corner.precedance ]) + camel(COLOR),
colorElem = this._useTitle(corner) && elements.titlebar || elements.content,
css = this._invalidColour, color = [];
// Attempt to detect the background colour from various elements, left-to-right precedance
color[0] = css(tip, BG_COLOR) || css(colorElem, BG_COLOR) || css(elements.content, BG_COLOR) ||
css(elements.tooltip, BG_COLOR) || tip.css(BG_COLOR);
// Attempt to detect the correct border side colour from various elements, left-to-right precedance
color[1] = css(tip, borderSide, COLOR) || css(colorElem, borderSide, COLOR) ||
css(elements.content, borderSide, COLOR) || css(elements.tooltip, borderSide, COLOR) || elements.tooltip.css(borderSide);
// Reset background and border colours
$('*', tip).add(tip).css('cssText', BG_COLOR+':'+TRANSPARENT+IMPORTANT+';'+BORDER+':0'+IMPORTANT+';');
return color;
},
_calculateSize: function(corner) {
var y = corner.precedance === Y,
width = this.options.width,
height = this.options.height,
isCenter = corner.abbrev() === 'c',
base = (y ? width: height) * (isCenter ? 0.5 : 1),
pow = Math.pow,
round = Math.round,
bigHyp, ratio, result,
smallHyp = Math.sqrt( pow(base, 2) + pow(height, 2) ),
hyp = [
this.border / base * smallHyp,
this.border / height * smallHyp
];
hyp[2] = Math.sqrt( pow(hyp[0], 2) - pow(this.border, 2) );
hyp[3] = Math.sqrt( pow(hyp[1], 2) - pow(this.border, 2) );
bigHyp = smallHyp + hyp[2] + hyp[3] + (isCenter ? 0 : hyp[0]);
ratio = bigHyp / smallHyp;
result = [ round(ratio * width), round(ratio * height) ];
return y ? result : result.reverse();
},
// Tip coordinates calculator
_calculateTip: function(corner, size, scale) {
scale = scale || 1;
size = size || this.size;
var width = size[0] * scale,
height = size[1] * scale,
width2 = Math.ceil(width / 2), height2 = Math.ceil(height / 2),
// Define tip coordinates in terms of height and width values
tips = {
br: [0,0, width,height, width,0],
bl: [0,0, width,0, 0,height],
tr: [0,height, width,0, width,height],
tl: [0,0, 0,height, width,height],
tc: [0,height, width2,0, width,height],
bc: [0,0, width,0, width2,height],
rc: [0,0, width,height2, 0,height],
lc: [width,0, width,height, 0,height2]
};
// Set common side shapes
tips.lt = tips.br; tips.rt = tips.bl;
tips.lb = tips.tr; tips.rb = tips.tl;
return tips[ corner.abbrev() ];
},
// Tip coordinates drawer (canvas)
_drawCoords: function(context, coords) {
context.beginPath();
context.moveTo(coords[0], coords[1]);
context.lineTo(coords[2], coords[3]);
context.lineTo(coords[4], coords[5]);
context.closePath();
},
create: function() {
// Determine tip corner
var c = this.corner = (HASCANVAS || BROWSER.ie) && this._parseCorner(this.options.corner);
// If we have a tip corner...
this.enabled = !!this.corner && this.corner.abbrev() !== 'c';
if(this.enabled) {
// Cache it
this.qtip.cache.corner = c.clone();
// Create it
this.update();
}
// Toggle tip element
this.element.toggle(this.enabled);
return this.corner;
},
update: function(corner, position) {
if(!this.enabled) { return this; }
var elements = this.qtip.elements,
tip = this.element,
inner = tip.children(),
options = this.options,
curSize = this.size,
mimic = options.mimic,
round = Math.round,
color, precedance, context,
coords, bigCoords, translate, newSize, border;
// Re-determine tip if not already set
if(!corner) { corner = this.qtip.cache.corner || this.corner; }
// Use corner property if we detect an invalid mimic value
if(mimic === FALSE) { mimic = corner; }
// Otherwise inherit mimic properties from the corner object as necessary
else {
mimic = new CORNER(mimic);
mimic.precedance = corner.precedance;
if(mimic.x === 'inherit') { mimic.x = corner.x; }
else if(mimic.y === 'inherit') { mimic.y = corner.y; }
else if(mimic.x === mimic.y) {
mimic[ corner.precedance ] = corner[ corner.precedance ];
}
}
precedance = mimic.precedance;
// Ensure the tip width.height are relative to the tip position
if(corner.precedance === X) { this._swapDimensions(); }
else { this._resetDimensions(); }
// Update our colours
color = this.color = this._parseColours(corner);
// Detect border width, taking into account colours
if(color[1] !== TRANSPARENT) {
// Grab border width
border = this.border = this._parseWidth(corner, corner[corner.precedance]);
// If border width isn't zero, use border color as fill if it's not invalid (1.0 style tips)
if(options.border && border < 1 && !INVALID.test(color[1])) { color[0] = color[1]; }
// Set border width (use detected border width if options.border is true)
this.border = border = options.border !== TRUE ? options.border : border;
}
// Border colour was invalid, set border to zero
else { this.border = border = 0; }
// Determine tip size
newSize = this.size = this._calculateSize(corner);
tip.css({
width: newSize[0],
height: newSize[1],
lineHeight: newSize[1]+'px'
});
// Calculate tip translation
if(corner.precedance === Y) {
translate = [
round(mimic.x === LEFT ? border : mimic.x === RIGHT ? newSize[0] - curSize[0] - border : (newSize[0] - curSize[0]) / 2),
round(mimic.y === TOP ? newSize[1] - curSize[1] : 0)
];
}
else {
translate = [
round(mimic.x === LEFT ? newSize[0] - curSize[0] : 0),
round(mimic.y === TOP ? border : mimic.y === BOTTOM ? newSize[1] - curSize[1] - border : (newSize[1] - curSize[1]) / 2)
];
}
// Canvas drawing implementation
if(HASCANVAS) {
// Grab canvas context and clear/save it
context = inner[0].getContext('2d');
context.restore(); context.save();
context.clearRect(0,0,6000,6000);
// Calculate coordinates
coords = this._calculateTip(mimic, curSize, SCALE);
bigCoords = this._calculateTip(mimic, this.size, SCALE);
// Set the canvas size using calculated size
inner.attr(WIDTH, newSize[0] * SCALE).attr(HEIGHT, newSize[1] * SCALE);
inner.css(WIDTH, newSize[0]).css(HEIGHT, newSize[1]);
// Draw the outer-stroke tip
this._drawCoords(context, bigCoords);
context.fillStyle = color[1];
context.fill();
// Draw the actual tip
context.translate(translate[0] * SCALE, translate[1] * SCALE);
this._drawCoords(context, coords);
context.fillStyle = color[0];
context.fill();
}
// VML (IE Proprietary implementation)
else {
// Calculate coordinates
coords = this._calculateTip(mimic);
// Setup coordinates string
coords = 'm' + coords[0] + ',' + coords[1] + ' l' + coords[2] +
',' + coords[3] + ' ' + coords[4] + ',' + coords[5] + ' xe';
// Setup VML-specific offset for pixel-perfection
translate[2] = border && /^(r|b)/i.test(corner.string()) ?
BROWSER.ie === 8 ? 2 : 1 : 0;
// Set initial CSS
inner.css({
coordsize: newSize[0]+border + ' ' + newSize[1]+border,
antialias: ''+(mimic.string().indexOf(CENTER) > -1),
left: translate[0] - translate[2] * Number(precedance === X),
top: translate[1] - translate[2] * Number(precedance === Y),
width: newSize[0] + border,
height: newSize[1] + border
})
.each(function(i) {
var $this = $(this);
// Set shape specific attributes
$this[ $this.prop ? 'prop' : 'attr' ]({
coordsize: newSize[0]+border + ' ' + newSize[1]+border,
path: coords,
fillcolor: color[0],
filled: !!i,
stroked: !i
})
.toggle(!!(border || i));
// Check if border is enabled and add stroke element
!i && $this.html( createVML(
'stroke', 'weight="'+border*2+'px" color="'+color[1]+'" miterlimit="1000" joinstyle="miter"'
) );
});
}
// Opera bug #357 - Incorrect tip position
// https://github.com/Craga89/qTip2/issues/367
window.opera && setTimeout(function() {
elements.tip.css({
display: 'inline-block',
visibility: 'visible'
});
}, 1);
// Position if needed
if(position !== FALSE) { this.calculate(corner, newSize); }
},
calculate: function(corner, size) {
if(!this.enabled) { return FALSE; }
var self = this,
elements = this.qtip.elements,
tip = this.element,
userOffset = this.options.offset,
position = {},
precedance, corners;
// Inherit corner if not provided
corner = corner || this.corner;
precedance = corner.precedance;
// Determine which tip dimension to use for adjustment
size = size || this._calculateSize(corner);
// Setup corners and offset array
corners = [ corner.x, corner.y ];
if(precedance === X) { corners.reverse(); }
// Calculate tip position
$.each(corners, function(i, side) {
var b, bc, br;
if(side === CENTER) {
b = precedance === Y ? LEFT : TOP;
position[ b ] = '50%';
position[MARGIN+'-' + b] = -Math.round(size[ precedance === Y ? 0 : 1 ] / 2) + userOffset;
}
else {
b = self._parseWidth(corner, side, elements.tooltip);
bc = self._parseWidth(corner, side, elements.content);
br = self._parseRadius(corner);
position[ side ] = Math.max(-self.border, i ? bc : userOffset + (br > b ? br : -b));
}
});
// Adjust for tip size
position[ corner[precedance] ] -= size[ precedance === X ? 0 : 1 ];
// Set and return new position
tip.css({ margin: '', top: '', bottom: '', left: '', right: '' }).css(position);
return position;
},
reposition: function(event, api, pos) {
if(!this.enabled) { return; }
var cache = api.cache,
newCorner = this.corner.clone(),
adjust = pos.adjusted,
method = api.options.position.adjust.method.split(' '),
horizontal = method[0],
vertical = method[1] || method[0],
shift = { left: FALSE, top: FALSE, x: 0, y: 0 },
offset, css = {}, props;
function shiftflip(direction, precedance, popposite, side, opposite) {
// Horizontal - Shift or flip method
if(direction === SHIFT && newCorner.precedance === precedance && adjust[side] && newCorner[popposite] !== CENTER) {
newCorner.precedance = newCorner.precedance === X ? Y : X;
}
else if(direction !== SHIFT && adjust[side]){
newCorner[precedance] = newCorner[precedance] === CENTER ?
adjust[side] > 0 ? side : opposite :
newCorner[precedance] === side ? opposite : side;
}
}
function shiftonly(xy, side, opposite) {
if(newCorner[xy] === CENTER) {
css[MARGIN+'-'+side] = shift[xy] = offset[MARGIN+'-'+side] - adjust[side];
}
else {
props = offset[opposite] !== undefined ?
[ adjust[side], -offset[side] ] : [ -adjust[side], offset[side] ];
if( (shift[xy] = Math.max(props[0], props[1])) > props[0] ) {
pos[side] -= adjust[side];
shift[side] = FALSE;
}
css[ offset[opposite] !== undefined ? opposite : side ] = shift[xy];
}
}
// If our tip position isn't fixed e.g. doesn't adjust with viewport...
if(this.corner.fixed !== TRUE) {
// Perform shift/flip adjustments
shiftflip(horizontal, X, Y, LEFT, RIGHT);
shiftflip(vertical, Y, X, TOP, BOTTOM);
// Update and redraw the tip if needed (check cached details of last drawn tip)
if(newCorner.string() !== cache.corner.string() || cache.cornerTop !== adjust.top || cache.cornerLeft !== adjust.left) {
this.update(newCorner, FALSE);
}
}
// Setup tip offset properties
offset = this.calculate(newCorner);
// Readjust offset object to make it left/top
if(offset.right !== undefined) { offset.left = -offset.right; }
if(offset.bottom !== undefined) { offset.top = -offset.bottom; }
offset.user = this.offset;
// Perform shift adjustments
shift.left = horizontal === SHIFT && !!adjust.left;
if(shift.left) {
shiftonly(X, LEFT, RIGHT);
}
shift.top = vertical === SHIFT && !!adjust.top;
if(shift.top) {
shiftonly(Y, TOP, BOTTOM);
}
/*
* If the tip is adjusted in both dimensions, or in a
* direction that would cause it to be anywhere but the
* outer border, hide it!
*/
this.element.css(css).toggle(
!(shift.x && shift.y || newCorner.x === CENTER && shift.y || newCorner.y === CENTER && shift.x)
);
// Adjust position to accomodate tip dimensions
pos.left -= offset.left.charAt ? offset.user :
horizontal !== SHIFT || shift.top || !shift.left && !shift.top ? offset.left + this.border : 0;
pos.top -= offset.top.charAt ? offset.user :
vertical !== SHIFT || shift.left || !shift.left && !shift.top ? offset.top + this.border : 0;
// Cache details
cache.cornerLeft = adjust.left; cache.cornerTop = adjust.top;
cache.corner = newCorner.clone();
},
destroy: function() {
// Unbind events
this.qtip._unbind(this.qtip.tooltip, this._ns);
// Remove the tip element(s)
if(this.qtip.elements.tip) {
this.qtip.elements.tip.find('*')
.remove().end().remove();
}
}
});
TIP = PLUGINS.tip = function(api) {
return new Tip(api, api.options.style.tip);
};
// Initialize tip on render
TIP.initialize = 'render';
// Setup plugin sanitization options
TIP.sanitize = function(options) {
if(options.style && 'tip' in options.style) {
var opts = options.style.tip;
if(typeof opts !== 'object') { opts = options.style.tip = { corner: opts }; }
if(!(/string|boolean/i).test(typeof opts.corner)) { opts.corner = TRUE; }
}
};
// Add new option checks for the plugin
CHECKS.tip = {
'^position.my|style.tip.(corner|mimic|border)$': function() {
// Make sure a tip can be drawn
this.create();
// Reposition the tooltip
this.qtip.reposition();
},
'^style.tip.(height|width)$': function(obj) {
// Re-set dimensions and redraw the tip
this.size = [ obj.width, obj.height ];
this.update();
// Reposition the tooltip
this.qtip.reposition();
},
'^content.title|style.(classes|widget)$': function() {
this.update();
}
};
// Extend original qTip defaults
$.extend(TRUE, QTIP.defaults, {
style: {
tip: {
corner: TRUE,
mimic: FALSE,
width: 6,
height: 6,
border: TRUE,
offset: 0
}
}
});