/*! * jQuery.PositionCalculator * https://github.com/tlindig/position-calculator * * v1.1.2 - 2015-10-15 * * Copyright (c) 2015 Tobias Lindig * http://tlindig.de * * License: MIT * * Author: Tobias Lindig */ /*! * class PositionCalculator * https://github.com/tlindig/position-calculator * * Copyright (c) 2014 Tobias Lindig * Licensed under the MIT license. */ /*global define:false, module:false*/ (function(factory) { // make it public if (typeof define === 'function' && define.amd) { // as __named__ AMD module define("position-calculator", ["jquery"], factory); } else if (typeof module === 'object' && module.exports) { module.exports = factory; } else { // as Browser globals jQuery.PositionCalculator = factory(jQuery); } }(function($) { "use strict"; //enable ECMAScript 5 Strict Mode // ////////// // private var __window = window; var __document = document; var __docElement = __document.documentElement; var __rgx_vertical = /top|middle|bottom/; var __rgx_horizontal = /left|center|right/; var __rgx_percent = /%$/; var __mirror = { left: "right", center: "center", right: "left", top: "bottom", middle: "middle", bottom: "top" }; /** * prepare selector, because jQuery do not return "window" and "document" * * @param {selector|DOM|jQuery|null} selector value given in options * @return {selector|DOM|jQuery|null} if "selector" was a string and match "window" or * "document", than the native object will be returned. */ function __normalizeSlector(selector) { if (typeof selector === "string") { if (selector === "window") { selector = __window; } else if (selector === "document") { selector = __document; } } return selector; } /** * Normalize the given "at" specification. * Use default value ('top left'), if syntax is not correct. * * @param {string} ref syntax: + " " + * vertical: "top" | "middle" | "bottom" * horizontal: "left" | "center" | "right" * @return {NormAt} Object with {y:string, x:string} */ function __normalizeAt(ref) { var values = ref.split(" "); return { y: __rgx_vertical.test(values[0]) ? values[0] : "top", x: __rgx_horizontal.test(values[1]) ? values[1] : "left" } } /** * compare to NormPos with {top:number, left:number, height:number, width:number} * * @param {NormPos} normPos1 * @param {NormPos} normPos2 * @return {boolean} true, if values are equal */ function __isEqualNormPos(normPos1, normPos2) { if (normPos1 === normPos2) { return true; } if(!normPos1 || !normPos2) { return false; } return (normPos1.top === normPos2.top && normPos1.left === normPos2.left && normPos1.height === normPos2.height && normPos1.width === normPos2.width); } /** * read the correct value for top, left, width and height from the given $el. * Can handle "window", "document", "event" and "DOM node" * resulting "top" and "left" are relative to document top-left corner * * @param {jQuery} $el input to calculate the position * @return {NormPos} Object with {top:number, left:number, height:number, width:number} * **/ function __nomrmalizePosition($el) { var raw = $el[0]; if (raw.nodeType === 9) { // is document node return { width: $el.outerWidth(), height: $el.outerHeight(), top: 0, left: 0 }; } if ($.isWindow(raw)) { return { width: $el.outerWidth(), height: $el.outerHeight(), top: $el.scrollTop(), left: $el.scrollLeft() }; } if (raw.preventDefault) { // is event return { width: 0, height: 0, top: raw.pageY, left: raw.pageX }; } var offset = $el.offset(); return { width: $el.outerWidth(), height: $el.outerHeight(), top: offset.top, left: offset.left }; } function __refreshPosition($el, normPos) { var raw = $el[0]; if (raw.nodeType === 9) { // is document node, top and left are always 0 return; } if ($.isWindow(raw)) { normPos.top = $el.scrollTop(); normPos.left = $el.scrollLeft(); } if (raw.preventDefault) { // is event normPos.top = raw.pageY; normPos.left = raw.pageX; return; } var offset = $el.offset(); normPos.top = offset.top; normPos.left = offset.left; return; } /** * get the inner boundary box of given element. Take care of scrollbars, borders, padding and so on. * Can handle "window", "document" and "DOM node" * resulting "top" and "left" are relative to document top-left corner * * @param {jQuery} $el [description] * @return {NormPos} Object with {top:number, left:number, height:number, width:number} */ function __normalizeBounding($el) { var domElm = $el[0]; var offset; if (domElm.nodeType === 9) { // is document node domElm = __docElement; offset = { top: 0, left: 0 }; } else if ($.isWindow(domElm)) { domElm = __docElement; offset = { top: $el.scrollTop(), left: $el.scrollLeft() }; } else { offset = $el.offset(); } return { width: domElm.clientWidth, height: domElm.clientHeight, top: offset.top + domElm.clientTop, left: offset.left + domElm.clientLeft }; } function __refreshBounding($el, normPos) { var domElm = $el[0]; var offset; if (domElm.nodeType === 9) { // is document node domElm = __docElement; offset = { top: 0, left: 0 }; } else if ($.isWindow(domElm)) { domElm = __docElement; offset = { top: $el.scrollTop(), left: $el.scrollLeft() }; } else { offset = $el.offset(); } normPos.top = offset.top + domElm.clientTop; normPos.left = offset.left + domElm.clientLeft; return; } /** * normalize given offset, convert percent values in pixel values. * * @param {Object} offset offset object with property x:{number}, y:{number}, mirror:{boolean} * @param {Object} size with properties width:{number} and height:{number} } * @return {Object} offset object */ function __normalizeExtraOffset(offset, size) { return { y: parseFloat(offset.y) * (__rgx_percent.test(offset.y) ? size.height / 100 : 1), x: parseFloat(offset.x) * (__rgx_percent.test(offset.x) ? size.width / 100 : 1), mirror: offset.mirror }; } /** * Calculate the relative offset from top-left corner to the reference points * * @param {NormPos} pos Object with normalized position * @param {{x:number, y:number}} extraOffsets [description] * @param {{x:string, y:string}} initialRefpoint [description] * @return {RefPoints} Object with offset for reference points * { top:number, left:number, middle:number, * center:number, bottom:number, right:number } */ function __calculateRefpointOffsets(pos, extraOffsets, initialRefpoint) { var result = { top: 0, left: 0, middle: pos.height * 0.5, center: pos.width * 0.5, bottom: pos.height, right: pos.width }; //add extra offset if (extraOffsets.y !== 0) { result.middle += extraOffsets.y; if (extraOffsets.mirror) { result.top += ("top" !== initialRefpoint.y) ? (extraOffsets.y * -1) : extraOffsets.y; result.bottom += ("bottom" !== initialRefpoint.y) ? (extraOffsets.y * -1) : extraOffsets.y; } else { result.top += extraOffsets.y; result.bottom += extraOffsets.y; } } if (extraOffsets.x !== 0) { result.center += extraOffsets.x; if (extraOffsets.mirror) { result.left += ("left" !== initialRefpoint.x) ? (extraOffsets.x * -1) : extraOffsets.x; result.right += ("right" !== initialRefpoint.x) ? (extraOffsets.x * -1) : extraOffsets.x; } else { result.left += extraOffsets.x; result.right += extraOffsets.x; } } return result; } /** * collect all edges that have overflow between boundary and item. * * @param {Distance} distance Distance Object * @return {Distance} Object with * { top:number, left:number, bottom:number, right:number, * overflow:{Array.|null} } */ function __updateOverflow(distance) { var overflow = []; distance.top > 0 && overflow.push("top"); distance.left > 0 && overflow.push("left"); distance.bottom < 0 && overflow.push("bottom"); distance.right < 0 && overflow.push("right"); if (overflow.length) { distance.overflow = overflow; } else { distance.overflow = null; } return distance; } /** * calculate distance / overflow between boundary and item. * * @param {NormPos} bou_Pos NormPos of boundary * @param {NormPos} item_Pos NormPos of item * @return {Distance} Object with * { top:number, left:number, bottom:number, right:number, * overflow:{Array.|null} } */ function __calulateDistance(bou_Pos, item_Pos) { var result = { top: bou_Pos.top - item_Pos.top, left: bou_Pos.left - item_Pos.left, bottom: (bou_Pos.top + bou_Pos.height) - (item_Pos.top + item_Pos.height), right: (bou_Pos.left + bou_Pos.width) - (item_Pos.left + item_Pos.width), overflow: [] }; return __updateOverflow(result); } /** * calculate the new fliped placement. * * {NormAt} is Object with {x:string, y:string} * * @param {string} flip - flip option, "item", "target", "both", "none" * @param {NormAt} itemAt - NormAt of item * @param {NormAt} tarAt - NormAt of target * @param {Distance} - current calculated distance, needed to find out, which edge have overflow * @return {Object|null} - Object with placement * { * item_at:NormAt, * tar_at:NormAt * } * - null, if no overflow or if overflow on all edges */ function __flipPlacement(flip, itemAt, tarAt, distance) { var y_overflowEdge, x_overflowEdge, flipBits; var item_flipedAt = { y: itemAt.y, x: itemAt.x }; var tar_flipedAt = { y: tarAt.y, x: tarAt.x }; if (distance.overflow.indexOf("top") !== -1) { y_overflowEdge = "top"; } if (distance.overflow.indexOf("bottom") !== -1) { if (y_overflowEdge) { //overflow in both sides, so item is larger than boundary. Can't be resolved y_overflowEdge = null; } else { y_overflowEdge = "bottom"; } } if (distance.overflow.indexOf("left") !== -1) { x_overflowEdge = "left"; } if (distance.overflow.indexOf("right") !== -1) { if (x_overflowEdge) { //overflow in both sides, so item is larger than boundary. Can't be resolved x_overflowEdge = null; } else { x_overflowEdge = "right"; } } if (!y_overflowEdge && !x_overflowEdge) { return null; } flip = (flip === true) ? "both" : flip; flipBits = 0; switch (flip) { case "item": flipBits = 1; break; case "target": flipBits = 2; break; case "both": flipBits = 3; break; } if (flipBits & 1) { y_overflowEdge && (item_flipedAt.y = __mirror[item_flipedAt.y]); x_overflowEdge && (item_flipedAt.x = __mirror[item_flipedAt.x]); } if (flipBits & 2) { y_overflowEdge && (tar_flipedAt.y = __mirror[tar_flipedAt.y]); x_overflowEdge && (tar_flipedAt.x = __mirror[tar_flipedAt.x]); } return { item_at: item_flipedAt, tar_at: tar_flipedAt }; } /** * compare overflow in distancaA with overflow in distanceB. * * @param {Distance} distanceA distance object, with top, right, bottom, left * @param {Distance} distanceB distance object, with top, right, bottom, left * @param {boolean} isY axis * @return {boolean} return true, if overflow of A is less than overflow of B, * otherwise false */ function __overflowLT(distanceA, distanceB, isY) { var a1, a2, b1, b2, edges; if (isY) { edges = ["top", "bottom"]; } else { edges = ["left", "right"]; } a1 = distanceA[edges[0]]; b1 = distanceB[edges[0]]; a2 = distanceA[edges[1]] * -1; // * -1 to get positive values for overflow b2 = distanceB[edges[1]] * -1; // set values without overflow to zero a1 < 0 && (a1 = 0); a2 < 0 && (a2 = 0); b1 < 0 && (b1 = 0); b2 < 0 && (b2 = 0); if (a1 < 0 && a2 < 0) { //take a return true; } if (b1 < 0 && b2 < 0) { // take b return false; } return (a1 + a2) < (b1 + b2); } function __adaptSticking(data, edges) { if (edges === "all") { edges = true; } var overflow = data.distance.overflow; if(!overflow.length) { return data; } //to prevent handling overflow in both directions of on axis var skipX = false; var skipY = false; var edge, diff; for (var i = overflow.length - 1; i >= 0; i--) { edge = overflow[i]; switch (edge) { case "top": case "bottom": if (!skipY && edges === true || edges.indexOf(edge) !== -1) { diff = data.distance[edge]; data.moveBy.y += diff; data.distance.top -= diff; data.distance.bottom -= diff; skipY = true; } break; case "left": case "right": if (!skipX && edges === true || edges.indexOf(edge) !== -1) { diff = data.distance[edge]; data.moveBy.x += diff; data.distance.left -= diff; data.distance.right -= diff; skipX = true; } break; } } __updateOverflow(data.distance); return data; } /** * Class PositionCalculator * * @param {Object} options * * {selector|DOM|jQuery} item -required- the element being positioned * {selector|DOM|jQuery} target -required- the element align the positioned item against * {selector|DOM|jQuery|null} boundary -optional- constraints the position of item * default: window * * {string} itemAt -optional- placement of reference point on the item * syntax: + " " + * vertical: "top" | "middle" | "bottom" * horizontal: "left" | "center" | "right" * default: "top left" * {string} targetAt -optional- placement of reference point on the target * same as for "itemAt" * default: "top left" * {Object} itemOffset -optional- Object with { * y:number, // vertical offset * x:number, // horizontal offset * mirror:boolean // if offset should mirror for flip * } * default: { y:0, x:0, mirror:true } * * {Object} targetOffset -optional- same as for "itemOffset" * default: { y:0, x:0, mirror:true } * * {string|boolean} flip -optional- specify the strategy to prevent that "item" * overflows the boundary. * "item": Only change the itemAt * "target": Only change the targetAt * "both"|true: Change both the itemAt and targetAt at the same time * (to 'flip' the item to the other side of the target) * "none"|false: Don't change placement of reference point * default: "none" * * {string|boolean} stick -optional- will keep the item within it's boundary by sticking it to * the edges if it normally would overflow. * Specify sides you'd like to control (space separated) or * "none"|false or "all"|true. * default: "none" * * * Main method is calculate() * */ function PositionCalculator(options) { //ensure it called with 'new' if (!(this instanceof PositionCalculator)) { return new PositionCalculator(options); } this.options = this.$itm = this.$trg = this.$bnd = this.itmAt = this.trgAt = this.itmPos = this.trgPos = this.bndPos = this.itmOffset = this.trgOffset = null; this._init(options); } PositionCalculator.prototype._init = function(options) { var o = this.options = $.extend({}, PositionCalculator.defaults, options); if (!o.item) { return null; } this.$itm = o.item.jquery ? o.item : $(o.item); if (this.$itm.length === 0) { return null; } this.$trg = o.target && o.target.jquery ? o.target : $(__normalizeSlector(o.target)); this.$bnd = o.boundary && o.boundary.jquery ? o.boundary : $(__normalizeSlector(o.boundary)); this.itmAt = __normalizeAt(o.itemAt); this.trgAt = __normalizeAt(o.targetAt); this.resize(); return this; // to allow chaining }; /** * Update intern stored values depending on size and position of elements (item, target, boundary). * Should be called if dimensions of an element changed. * * @return {this} allow chaining */ PositionCalculator.prototype.resize = function() { var o = this.options; var item_pos = __nomrmalizePosition(this.$itm); var targ_pos = this.$trg.length ? __nomrmalizePosition(this.$trg) : null; this.bndPos = this.$bnd.length ? __normalizeBounding(this.$bnd) : null; if (!this.itmPos || !__isEqualNormPos(item_pos, this.itmPos)) { this.itmPos = item_pos; var item_extraOffset = __normalizeExtraOffset(o.itemOffset, item_pos); // negate values, because it shall be defined relative to the item reference point // and not relative to the corner. item_extraOffset.x = item_extraOffset.x * -1; item_extraOffset.y = item_extraOffset.y * -1; this.itmOffset = __calculateRefpointOffsets(item_pos, item_extraOffset, this.itmAt); } if (!this.trgPos || !__isEqualNormPos(targ_pos, this.trgPos)) { this.trgPos = targ_pos; if(targ_pos) { this.trgOffset = __calculateRefpointOffsets( targ_pos, __normalizeExtraOffset(o.targetOffset, targ_pos), this.trgAt ); } } return this; // to allow chaining }; /** * Calculate the resulting position and boundary distance for the given placement. * That will not handle flip and fit. * * If target was not specified, only boundary distance will be calculated. * If not "item_at" or "tar_at", only boundary distance will be calculated. * If boundary was set to null, only new position will be calculated. * * Current position of elements (item, target, boundary) will be read from DOM. * * @param {{x:string, y:string}|null} item_at Placement for reference point on item * @param {{x:string, y:string}|null} tar_at Placement for reference point on target * @return {Object} CalculationResult, see method calculate() */ PositionCalculator.prototype.calcVariant = function(item_at, tar_at) { var result = { moveBy: null, distance: null, itemAt: null, targetAt: null }; if(this.trgPos && item_at && tar_at) { var tar_refpoint = { top: this.trgPos.top + this.trgOffset[tar_at.y], left: this.trgPos.left + this.trgOffset[tar_at.x] }; var item_newPos = { top: tar_refpoint.top - this.itmOffset[item_at.y], left: tar_refpoint.left - this.itmOffset[item_at.x], height: this.itmPos.height, width: this.itmPos.width }; result.moveBy = { y: item_newPos.top - this.itmPos.top, x: item_newPos.left - this.itmPos.left }; result.distance = this.bndPos ? __calulateDistance(this.bndPos, item_newPos) : null; result.itemAt = item_at.y + " " + item_at.x; result.targetAt = tar_at.y + " " + tar_at.x; } else { result.moveBy = { y:0, x:0 }; result.distance = this.bndPos ? __calulateDistance(this.bndPos, this.itmPos) : null; } return result; }; /** * Calculate the distance between reference point of item and reference point of target and * handle overflow in the specified matter. * * @return {Object} with: * moveBy: {{y:number, x:number}} - distance between target and item as pixel values * distance: {Distance|null} - distance between item and boundary * null, if boundary was not given * Distance is Object with: { * top:number, left:number, * bottom:number, right:number, * overflow:{Array.|null} * } * - top, left, buttom, right - distance/overflow for this edge * - overflow - Array with edges has overflow * - null for no collision detected * itemAt: {string|null} - used placement of reference point at item * syntax: + " " + * vertical: "top" | "middle" | "bottom" * horizontal: "left" | "center" | "right" * - null, if target was not given * targetAt: {string|null} - used placement of reference point at target * syntax: + " " + * vertical: "top" | "middle" | "bottom" * horizontal: "left" | "center" | "right" * - null, if target was not given */ PositionCalculator.prototype.calculate = function() { if (this.itmPos === null) { return null; // init failed } var o = this.options; // refresh // only update the position off elements and scroll offsets, but not the width or height __refreshPosition(this.$itm, this.itmPos); this.trgPos && __refreshPosition(this.$trg, this.trgPos); this.bndPos && __refreshBounding(this.$bnd, this.bndPos); var result = this.calcVariant(this.itmAt, this.trgAt); if (!result.distance || !result.distance.overflow) { //finish, because no collision return result; } // //////////////////// // collision handling: flip if (o.flip && o.flip !== "none" && this.trgPos) { var newResult; var flipedPlacement = __flipPlacement(o.flip, this.itmAt, this.trgAt, result.distance); if (flipedPlacement) { newResult = this.calcVariant(flipedPlacement.item_at, flipedPlacement.tar_at); if (!newResult.distance.overflow) { //finish, because found placement without collision return newResult; } // look for combination with fewest overflow var useNew = { y: false, x: false }; useNew.y = __overflowLT(newResult.distance, result.distance, true); useNew.x = __overflowLT(newResult.distance, result.distance, false); if (useNew.y !== useNew.x) { //need new distance calculation result = this.calcVariant({ y: useNew.y ? flipedPlacement.item_at.y : this.itmAt.y, x: useNew.x ? flipedPlacement.item_at.x : this.itmAt.x }, { y: useNew.y ? flipedPlacement.tar_at.y : this.trgAt.y, x: useNew.x ? flipedPlacement.tar_at.x : this.trgAt.x }); if (!result.distance.overflow) { //finish, because found position without collision return result; } } else if (useNew.y && useNew.x) { result = newResult; } // else use "old" result } } // //////////////////// // collision handling: stick if (o.stick && o.stick !== "none") { return __adaptSticking(result, o.stick); } else { return result; } }; // default options PositionCalculator.defaults = { item: null, target: null, boundary: window, itemAt: "top left", targetAt: "top left", itemOffset: { y: 0, x: 0, mirror: true }, targetOffset: { y: 0, x: 0, mirror: true }, flip: "none", stick: "none" }; // export return PositionCalculator; }));