Files
driver.js/src/index.js
T

452 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Overlay from './core/overlay';
import Element from './core/element';
import Popover from './core/popover';
import {
CLASS_CLOSE_BTN,
CLASS_NEXT_STEP_BTN,
CLASS_PREV_STEP_BTN,
ESC_KEY_CODE,
ID_POPOVER,
LEFT_KEY_CODE,
OVERLAY_OPACITY,
OVERLAY_PADDING,
RIGHT_KEY_CODE,
SHOULD_ANIMATE_OVERLAY,
SHOULD_OUTSIDE_CLICK_CLOSE,
SHOULD_OUTSIDE_CLICK_NEXT,
ALLOW_KEYBOARD_CONTROL,
} from './common/constants';
import Stage from './core/stage';
import { isDomElement } from './common/utils';
/**
* Plugin class that drives the plugin
*/
export default class Driver {
/**
* @param {Object} options
*/
constructor(options = {}) {
this.options = {
animate: SHOULD_ANIMATE_OVERLAY, // Whether to animate or not
opacity: OVERLAY_OPACITY, // Overlay opacity
padding: OVERLAY_PADDING, // Spacing around the element from the overlay
scrollIntoViewOptions: null, // Options to be passed to `scrollIntoView`
allowClose: SHOULD_OUTSIDE_CLICK_CLOSE, // Whether to close overlay on click outside the element
keyboardControl: ALLOW_KEYBOARD_CONTROL, // Whether to allow controlling through keyboard or not
overlayClickNext: SHOULD_OUTSIDE_CLICK_NEXT, // Whether to move next on click outside the element
stageBackground: '#ffffff', // Background color for the stage
onHighlightStarted: () => null, // When element is about to be highlighted
onHighlighted: () => null, // When element has been highlighted
onDeselected: () => null, // When the element has been deselected
onReset: () => null, // When overlay is about to be cleared
onNext: () => null, // When next button is clicked
onPrevious: () => null, // When previous button is clicked
...options,
};
this.document = document;
this.window = window;
this.isActivated = false;
this.steps = []; // steps to be presented if any
this.currentStep = 0; // index for the currently highlighted step
this.currentMovePrevented = false; // If the current move was prevented
this.overlay = new Overlay(this.options, window, document);
this.onResize = this.onResize.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.onClick = this.onClick.bind(this);
this.moveNext = this.moveNext.bind(this);
this.movePrevious = this.movePrevious.bind(this);
this.preventMove = this.preventMove.bind(this);
// Event bindings
this.bind();
}
/**
* Getter for steps property
* @readonly
* @public
*/
getSteps() {
return this.steps;
}
/**
* Setter for steps property
* @param steps
* @public
*/
setSteps(steps) {
this.steps = steps;
}
/**
* Binds any DOM events listeners
* @todo: add throttling in all the listeners
* @private
*/
bind() {
this.window.addEventListener('resize', this.onResize, false);
this.window.addEventListener('keyup', this.onKeyUp, false);
this.window.addEventListener('click', this.onClick, false);
this.window.addEventListener('touchstart', this.onClick, false);
}
/**
* Removes the popover if clicked outside the highlighted element
* or outside the
* @param e
* @private
*/
onClick(e) {
if (!this.isActivated || !this.hasHighlightedElement()) {
return;
}
// Stop the event propagation on click/tap. `onClick` handles
// both touch and click events – which on some browsers causes
// the click to close the tour
e.stopPropagation();
const highlightedElement = this.overlay.getHighlightedElement();
const popover = this.document.getElementById(ID_POPOVER);
const clickedHighlightedElement = highlightedElement.node.contains(e.target);
const clickedPopover = popover && popover.contains(e.target);
if (!clickedHighlightedElement && !clickedPopover && this.options.overlayClickNext) {
this.handleNext();
return;
}
// Remove the overlay If clicked outside the highlighted element
if (!clickedHighlightedElement && !clickedPopover && this.options.allowClose) {
this.reset();
return;
}
const nextClicked = e.target.classList.contains(CLASS_NEXT_STEP_BTN);
const prevClicked = e.target.classList.contains(CLASS_PREV_STEP_BTN);
const closeClicked = e.target.classList.contains(CLASS_CLOSE_BTN);
if (closeClicked) {
this.reset();
return;
}
if (nextClicked) {
this.handleNext();
} else if (prevClicked) {
this.handlePrevious();
}
}
/**
* Handler for the onResize DOM event
* Makes sure highlighted element stays at valid position
* @private
*/
onResize() {
if (!this.isActivated) {
return;
}
this.refresh();
}
/**
* Refreshes and repositions the popover and the overlay
*/
refresh() {
this.overlay.refresh();
}
/**
* Clears the overlay on escape key process
* @param event
* @private
*/
onKeyUp(event) {
// If driver is not active or keyboard control is disabled
if (!this.isActivated || !this.options.keyboardControl) {
return;
}
// If escape was pressed and it is allowed to click outside to close
if (event.keyCode === ESC_KEY_CODE) {
this.reset();
return;
}
// Arrow keys to only perform if it is stepped introduction
if (this.steps.length !== 0) {
if (event.keyCode === RIGHT_KEY_CODE) {
this.handleNext();
} else if (event.keyCode === LEFT_KEY_CODE) {
this.handlePrevious();
}
}
}
/**
* Moves to the previous step if possible
* otherwise resets the overlay
* @public
*/
movePrevious() {
const previousStep = this.steps[this.currentStep - 1];
if (!previousStep) {
this.reset();
return;
}
this.overlay.highlight(previousStep);
this.currentStep -= 1;
}
/**
* Prevents the current move. Useful in `onNext` if you want to
* perform some asynchronous task and manually move to next step
* @public
*/
preventMove() {
this.currentMovePrevented = true;
}
/**
* Handles the internal "move to next" event
* @private
*/
handleNext() {
this.currentMovePrevented = false;
// Call the bound `onNext` handler if available
const currentStep = this.steps[this.currentStep];
if (currentStep.options.onNext) {
currentStep.options.onNext(this.overlay.highlightedElement);
}
if (this.currentMovePrevented) {
return;
}
this.moveNext();
}
/**
* Handles the internal "move to previous" event
* @private
*/
handlePrevious() {
this.currentMovePrevented = false;
// Call the bound `onPrevious` handler if available
const currentStep = this.steps[this.currentStep];
if (currentStep.options.onPrevious) {
currentStep.options.onPrevious(this.overlay.highlightedElement);
}
if (this.currentMovePrevented) {
return;
}
this.movePrevious();
}
/**
* Moves to the next step if possible
* otherwise resets the overlay
* @public
*/
moveNext() {
const nextStep = this.steps[this.currentStep + 1];
if (!nextStep) {
this.reset();
return;
}
this.overlay.highlight(nextStep);
this.currentStep += 1;
}
/**
* @returns {boolean}
* @public
*/
hasNextStep() {
return !!this.steps[this.currentStep + 1];
}
/**
* @returns {boolean}
* @public
*/
hasPreviousStep() {
return !!this.steps[this.currentStep - 1];
}
/**
* Resets the steps if any and clears the overlay
* @param {boolean} immediate
* @public
*/
reset(immediate = false) {
this.currentStep = 0;
this.isActivated = false;
this.overlay.clear(immediate);
}
/**
* Checks if there is any highlighted element or not
* @returns {boolean}
* @public
*/
hasHighlightedElement() {
const highlightedElement = this.overlay.getHighlightedElement();
return highlightedElement && highlightedElement.node;
}
/**
* Gets the currently highlighted element in overlay
* @returns {Element}
* @public
*/
getHighlightedElement() {
return this.overlay.getHighlightedElement();
}
/**
* Gets the element that was highlighted before currently highlighted element
* @returns {Element}
* @public
*/
getLastHighlightedElement() {
return this.overlay.getLastHighlightedElement();
}
/**
* Defines steps to be highlighted
* @param {array} steps
* @public
*/
defineSteps(steps) {
this.steps = [];
for (let counter = 0; counter < steps.length; counter++) {
const element = this.prepareElementFromStep(steps[counter], steps, counter);
if (!element) {
continue;
}
this.steps.push(element);
}
}
/**
* Prepares the step received from the user and returns an instance
* of Element
*
* @param currentStep Step that is being prepared
* @param allSteps List of all the steps
* @param index Index of the current step
* @returns {null|Element}
* @private
*/
prepareElementFromStep(currentStep, allSteps = [], index = 0) {
let elementOptions = {};
let querySelector = currentStep;
// If the `currentStep` is step definition
// then grab the options and element from the definition
const isStepDefinition = typeof currentStep !== 'string' && !isDomElement(currentStep);
if (!currentStep || (isStepDefinition && !currentStep.element)) {
throw new Error(`Element is required in step ${index}`);
}
if (isStepDefinition) {
querySelector = currentStep.element;
elementOptions = { ...this.options, ...currentStep };
}
// If the given element is a query selector or a DOM element?
const domElement = isDomElement(querySelector) ? querySelector : this.document.querySelector(querySelector);
if (!domElement) {
console.warn(`Element to highlight ${querySelector} not found`);
return null;
}
let popover = null;
if (elementOptions.popover && elementOptions.popover.title) {
const mergedClassNames = [
this.options.className,
elementOptions.popover.className,
].filter(c => c).join(' ');
const popoverOptions = {
...this.options,
...elementOptions.popover,
className: mergedClassNames,
totalCount: allSteps.length,
currentIndex: index,
isFirst: index === 0,
isLast: index === allSteps.length - 1,
};
popover = new Popover(popoverOptions, this.window, this.document);
}
const stageOptions = {
...this.options,
...elementOptions,
};
const stage = new Stage(stageOptions, this.window, this.document);
return new Element({
node: domElement,
options: elementOptions,
popover,
stage,
overlay: this.overlay,
window: this.window,
document: this.document,
});
}
/**
* Initiates highlighting steps from first step
* @param {number} index at which highlight is to be started
* @public
*/
start(index = 0) {
if (!this.steps || this.steps.length === 0) {
throw new Error('There are no steps defined to iterate');
}
this.isActivated = true;
this.currentStep = index;
this.overlay.highlight(this.steps[index]);
}
/**
* Highlights the given element
* @param {string|{element: string, popover: {}}} selector Query selector or a step definition
* @public
*/
highlight(selector) {
this.isActivated = true;
const element = this.prepareElementFromStep(selector);
if (!element) {
return;
}
this.overlay.highlight(element);
}
}