mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-11-02 13:41:35 +08:00
452 lines
12 KiB
JavaScript
452 lines
12 KiB
JavaScript
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);
|
||
}
|
||
}
|