Skip to content
This repository has been archived by the owner on Feb 17, 2021. It is now read-only.

Commit

Permalink
First stab at callout and arrow positioning logic. Adding CalloutPlac…
Browse files Browse the repository at this point in the history
…ementManager.js
  • Loading branch information
kate2753 committed Sep 25, 2015
1 parent e8a1dfe commit 465371d
Show file tree
Hide file tree
Showing 3 changed files with 322 additions and 6 deletions.
229 changes: 229 additions & 0 deletions src/es/managers/CalloutPlacementManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import * as Utils from '../modules/utils.js';

/*
CalloutPlacementManager handles evertyhing related to callout positioning,
including arrow position, placement of the callout, respositioning of the callout
for responsive designs, etc.
*/

/* PRIVATE FUNCTIONS AND VARIABLES FOR THIS MODULE */

function setArrowPositionVertical(arrowEl, calloutEl, horizontalProp, arrowOffset) {
arrowEl.style.top = '';
if (arrowOffset == 'center') {
arrowEl.style[horizontalProp] = Math.floor((calloutEl.offsetWidth / 2) - arrowEl.offsetWidth / 2) + 'px';
} else {
arrowOffset = Utils.getPixelValue(arrowOffset);
if (arrowOffset) {
arrowEl.style[horizontalProp] = arrowOffset + 'px';
} else {
arrowEl.style[horizontalProp] = '';
}
}
}

function setArrowPositionHorizontal(arrowEl, calloutEl, horizontalProp, arrowOffset) {
arrowEl.style[horizontalProp] = '';

if (arrowOffset == 'center') {
arrowEl.style.top = Math.floor((calloutEl.offsetHeight / 2) - arrowEl.offsetHeight / 2) + 'px';
} else {
arrowOffset = Utils.getPixelValue(arrowOffset);
if (arrowOffset) {
arrowEl.style.top = arrowOffset + 'px';
} else {
arrowEl.style[horizontalProp] = '';
}
}
}

let placementStrategies = {
'top': {
arrowPlacement: 'down',
calculateCalloutPosition(targetElBox, calloutElBox, isRtl, arrowWidth) {
let verticalLeftPosition = isRtl ? targetElBox.right - calloutElBox.width : targetElBox.left;
let top = (targetElBox.top - calloutElBox.height) - arrowWidth;
let left = verticalLeftPosition;
return { top, left };
},
setArrowPosition: setArrowPositionVertical
},
'bottom': {
arrowPlacement: 'up',
calculateCalloutPosition(targetElBox, calloutElBox, isRtl, arrowWidth) {
let verticalLeftPosition = isRtl ? targetElBox.right - calloutElBox.width : targetElBox.left;
let top = targetElBox.bottom + arrowWidth;
let left = verticalLeftPosition;
return { top, left };
},
setArrowPosition: setArrowPositionVertical
},
'left': {
arrowPlacement: 'right',
rtlPlacement: 'right',
calculateCalloutPosition(targetElBox, calloutElBox, isRtl, arrowWidth) {
let top = targetElBox.top;
let left = targetElBox.left - calloutElBox.width - arrowWidth;
return { top, left };
},
setArrowPosition: setArrowPositionHorizontal
},
'right': {
arrowPlacement: 'left',
rtlPlacement: 'left',
calculateCalloutPosition(targetElBox, calloutElBox, isRtl, arrowWidth) {
let top = targetElBox.top;
let left = targetElBox.right + arrowWidth;
return { top, left };
},
setArrowPosition: setArrowPositionHorizontal
}
};


/**
* If step is right-to-left enabled, flip the placement and xOffset.
* Will adjust placement only once and will set _isFlippedForRtl option to keep track of this
* If placement is set on a global or tour level and callout does not have this config
* tour\global config will stay intact and new setting will be added into the callout's config
* @private
*/
function adjustPlacementForRtl(callout, placementStrategy) {
let isRtl = callout.config.get('isRtl');
let isFlippedForRtl = callout.config.get('_isFlippedForRtl');

if (isRtl && !isFlippedForRtl) {
let calloutXOffset = callout.config.set('xOffset');
let rtlPlacement = placementStrategy.rtlPlacement;

//flip xOffset
if (calloutXOffset) {
callout.config.set('xOffset', -1 * Utils.getPixelValue(calloutXOffset));
}
//flip placement for right and left placements only
if (rtlPlacement) {
callout.config.set('placement', rtlPlacement);
}
}
}

/**
* Adds correct placement class to the callout's arrow element if it exists.
* Arrow class will be determined based on Callout's 'placement' option.
* @private
*/
function positionArrow(callout, placementStrategy) {
let arrowEl = callout.el.querySelector('.hopscotch-arrow');
if (!arrowEl) {
return;
}

//Remove any stale position classes
arrowEl.classList.remove('down');
arrowEl.classList.remove('up');
arrowEl.classList.remove('right');
arrowEl.classList.remove('left');

//Have arrow point in the direction of the target
arrowEl.classList.add(placementStrategy.arrowPlacement);

//Position arrow correctly relative to the callout
let arrowOffset = callout.config.get('arrowOffset');
let horizontalProp = callout.config.get('isRtl') ? 'right' : 'left';
placementStrategy.setArrowPosition(arrowEl, callout.el, horizontalProp, arrowOffset);
}


/**
*
*
*/
function positionCallout(callout, placementStrategy) {
let targetEl = Utils.getTargetEl(callout.config.get('target'));
if (!targetEl) {
return;
}

let isFixedEl = callout.config.get('fixedElement');
let targetElBox = targetEl.getBoundingClientRect();
let calloutElBox = { width: callout.el.offsetWidth, height: callout.el.offsetHeight };
let calloutPosition = placementStrategy.calculateCalloutPosition(
targetElBox,
calloutElBox,
callout.config.get('isRtl'),
callout.config.get('arrowWidth')
);

//Adjust position if xOffset and yOffset are specified
//horizontal offset
let xOffset = callout.config.get('xOffset');
if (xOffset === 'center') {
calloutPosition.left = (targetElBox.left + targetEl.offsetWidth / 2) - (calloutElBox.width / 2);
}
else {
calloutPosition.left += Utils.getPixelValue(xOffset);
}
//vertical offset
let yOffset = callout.config.get('yOffset');
if (yOffset === 'center') {
calloutPosition.top = (targetElBox.top + targetEl.offsetHeight / 2) - (calloutElBox.height / 2);
}
else {
calloutPosition.top += Utils.getPixelValue(yOffset);
}

// Adjust TOP for scroll position
if (!isFixedEl) {
let scrollPosition = getScrollPosition();
calloutPosition.top += scrollPosition.top;
calloutPosition.left += scrollPosition.left;
}

//Set the position
callout.el.style.position = isFixedEl ? 'fixed' : 'absolute';
callout.el.style.top = calloutPosition.top + 'px';
callout.el.style.left = calloutPosition.left + 'px';
}

/**
* Returns top and left scroll positions
* @private
*/
export function getScrollPosition() {
let top;
let left;

if (typeof window.pageYOffset !== 'undefined') {
top = window.pageYOffset;
left = window.pageXOffset;
}
else {
// Most likely IE <=8, which doesn't support pageYOffset
top = document.documentElement.scrollTop;
left = document.documentElement.scrollLeft;
}
return { top, left };
}
/* END PRIVATE FUNCTIONS AND VARIABLES FOR THIS MODULE */
/* PUBLIC INTERFACE AND EXPORT STATEMENT FOR THIS MODULE */

let CalloutPlacementManager = {
setCalloutPosition(callout) {
//make sure that placement is set to a valid value
let placementStrategy = placementStrategies[callout.config.get('placement')];
if (!placementStrategy) {
throw new Error('Bubble placement failed because placement is invalid or undefined!');
}
//if callout is RTL enabled we need to adjust
//placement and xOffset values
adjustPlacementForRtl(callout, placementStrategy);
//adjust position of the callout element
//to be placed next to the target
positionCallout(callout, placementStrategy);
//update callout's arrow to point
//in the direction of the target element
positionArrow(callout, placementStrategy);
}
};
export default CalloutPlacementManager;
/* END PUBLIC INTERFACE AND EXPORT STATEMENT FOR THIS MODULE */
7 changes: 3 additions & 4 deletions src/es/modules/callout.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Config from './config.js';
import TemplateManager from '../managers/TemplateManager.js';
import CalloutPlacementManager from '../managers/CalloutPlacementManager.js';


//Abstract base class for callouts
export class Callout {
Expand All @@ -13,7 +15,7 @@ export class Callout {
this.config.get('renderer'),
this.getRenderData()
);

CalloutPlacementManager.setCalloutPosition(this);
document.body.appendChild(this.el);
}
show() {
Expand All @@ -24,9 +26,6 @@ export class Callout {
}
destroy() {
this.el.parentNode.removeChild(this.el);
}
setPosition () {

}
getRenderData() {
return {
Expand Down
92 changes: 90 additions & 2 deletions src/es/modules/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,93 @@
let validIdRegEx = /^[a-zA-Z]+[a-zA-Z0-9_-]*$/;

export function isIdValid(id) {
return id && validIdRegEx.test(id);
}
return id && validIdRegEx.test(id);
}

export function getPixelValue(val) {
let valType = typeof val;
if (valType === 'number') { return val; }
if (valType === 'string') { return parseInt(val, 10); }
return 0;
}

/**
* Private function to get a single target DOM element. We will try to
* locate the DOM element through several ways, in the following order:
*
* 1) Passing the string into document.querySelector
* 2) Passing the string to jQuery, if it exists
* 3) Passing the string to Sizzle, if it exists
* 4) Calling document.getElementById if it is a plain id
*
* Default case is to assume the string is a plain id and call
* document.getElementById on it.
*
* @private
*/
function getElement(element) {
let result = document.getElementById(element);

//Backwards compatibility: assume the string is an id
if (result) {
return result;
}
if (hasJquery) {
result = jQuery(element);
return result.length ? result[0] : null;
}
if (Sizzle) {
result = new Sizzle(element);
return result.length ? result[0] : null;
}
if (document.querySelector) {
try {
return document.querySelector(element);
} catch (err) { }
}
// Regex test for id. Following the HTML 4 spec for valid id formats.
// (http://www.w3.org/TR/html4/types.html#type-id)
if (/^#[a-zA-Z][\w-_:.]*$/.test(element)) {
return document.getElementById(element.substring(1));
}

return null;
}

/**
* Given a step, returns the target DOM element associated with it. It is
* recommended to only assign one target per However, there are
* some use cases which require multiple step targets to be supplied. In
* this event, we will use the first target in the array that we can
* locate on the page. See the comments for getElement for more
* information.
*
*/
export function getTargetEl(target) {
let queriedTarget;

if (!target) {
return null;
}

if (typeof target === 'string') {
//Just one target to test. Check and return its results.
return getElement(target);
}
else if (Array.isArray(target)) {
// Multiple items to check. Check each and return the first success.
// Assuming they are all strings.
for (let i = 0, len = target.length; i < len; i++) {
if (typeof target[i] === 'string') {
queriedTarget = getElement(target[i]);
if (queriedTarget) {
return queriedTarget;
}
}
}
return null;
}

// Assume that the target is a DOM element
return target;
}

0 comments on commit 465371d

Please sign in to comment.