From 465371d4cf0d27b1188f704a97c75160a171e4d6 Mon Sep 17 00:00:00 2001 From: Kate Odnous Date: Fri, 25 Sep 2015 16:01:29 -0700 Subject: [PATCH] First stab at callout and arrow positioning logic. Adding CalloutPlacementManager.js --- src/es/managers/CalloutPlacementManager.js | 229 +++++++++++++++++++++ src/es/modules/callout.js | 7 +- src/es/modules/utils.js | 92 ++++++++- 3 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 src/es/managers/CalloutPlacementManager.js diff --git a/src/es/managers/CalloutPlacementManager.js b/src/es/managers/CalloutPlacementManager.js new file mode 100644 index 00000000..3ce1afc1 --- /dev/null +++ b/src/es/managers/CalloutPlacementManager.js @@ -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 */ \ No newline at end of file diff --git a/src/es/modules/callout.js b/src/es/modules/callout.js index a8bd4a4f..10de1071 100644 --- a/src/es/modules/callout.js +++ b/src/es/modules/callout.js @@ -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 { @@ -13,7 +15,7 @@ export class Callout { this.config.get('renderer'), this.getRenderData() ); - + CalloutPlacementManager.setCalloutPosition(this); document.body.appendChild(this.el); } show() { @@ -24,9 +26,6 @@ export class Callout { } destroy() { this.el.parentNode.removeChild(this.el); - } - setPosition () { - } getRenderData() { return { diff --git a/src/es/modules/utils.js b/src/es/modules/utils.js index c45f8352..8e4f2c1c 100644 --- a/src/es/modules/utils.js +++ b/src/es/modules/utils.js @@ -1,5 +1,93 @@ let validIdRegEx = /^[a-zA-Z]+[a-zA-Z0-9_-]*$/; export function isIdValid(id) { - return id && validIdRegEx.test(id); -} \ No newline at end of file + 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; +}