@covalent/guided-tour
Version:
Covalent Guided Tour Module
627 lines (621 loc) • 28.9 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import * as i2 from '@angular/common/http';
import * as i1 from '@angular/router';
import { NavigationStart } from '@angular/router';
import { first, takeUntil, skip, skipWhile, filter, debounceTime, tap, map } from 'rxjs/operators';
import { Subject, merge, fromEvent, forkJoin, BehaviorSubject, timer } from 'rxjs';
import Shepherd from 'shepherd.js';
var ITourEvent;
(function (ITourEvent) {
ITourEvent["click"] = "click";
ITourEvent["pointerover"] = "pointerover";
ITourEvent["keyup"] = "keyup";
ITourEvent["added"] = "added";
ITourEvent["removed"] = "removed";
})(ITourEvent || (ITourEvent = {}));
class TourButtonsActions {
}
const SHEPHERD_DEFAULT_FIND_TIME_BEFORE_SHOW = 100;
const SHEPHERD_DEFAULT_FIND_INTERVAL = 500;
const SHEPHERD_DEFAULT_FIND_ATTEMPTS = 20;
const overriddenEvents = [
ITourEvent.click,
ITourEvent.pointerover,
ITourEvent.removed,
ITourEvent.added,
ITourEvent.keyup,
];
const keyEvents = new Map([
[13, 'enter'],
[27, 'esc'],
]);
const defaultStepOptions = {
scrollTo: { behavior: 'smooth', block: 'center' },
cancelIcon: {
enabled: true,
},
};
const MAT_ICON_BUTTON = 'mdc-icon-button material-icons mat-mdc-icon-button mat-mdc-button-base';
const MAT_BUTTON = 'mdc-button mat-mdc-button mat-mdc-button-base';
const MAT_BUTTON_INVISIBLE = 'shepherd-void-button';
class CovalentGuidedTour extends TourButtonsActions {
_destroyedEvent$;
shepherdTour;
stepOptions;
constructor(stepOptions = defaultStepOptions) {
super();
this.stepOptions = stepOptions;
this.newTour();
}
newTour(opts) {
this.shepherdTour = new Shepherd.Tour(Object.assign({
defaultStepOptions: this.stepOptions,
}, opts));
this._destroyedEvent$ = new Subject();
// listen to cancel and complete to clean up abortOn events
merge(fromEvent(this.shepherdTour, 'cancel'), fromEvent(this.shepherdTour, 'complete'))
.pipe(first())
.subscribe(() => {
this._destroyedEvent$.next();
this._destroyedEvent$.complete();
});
// if abortOn was passed, we bind the event and execute complete
if (opts && opts.abortOn) {
const abortArr$ = [];
opts.abortOn.forEach((abortOn) => {
const abortEvent$ = new Subject();
abortArr$.push(abortEvent$);
this._bindEvent(abortOn, undefined, abortEvent$, this._destroyedEvent$);
});
const abortSubs = merge(...abortArr$)
.pipe(takeUntil(this._destroyedEvent$))
.subscribe(() => {
this.shepherdTour.complete();
abortSubs.unsubscribe();
});
}
}
back() {
this.shepherdTour.back();
}
cancel() {
this.shepherdTour.cancel();
}
next() {
this.shepherdTour.next();
}
finish() {
this.shepherdTour.complete();
}
addSteps(steps) {
this.shepherdTour.addSteps(this._prepareTour(steps));
}
start() {
this.shepherdTour.start();
}
_prepareTour(originalSteps, finishLabel = 'finish') {
// create Subjects for back and forward events
const backEvent$ = new Subject();
const forwardEvent$ = new Subject();
let _backFlow = false;
// create Subject for your end
const destroyedEvent$ = new Subject();
/**
* This function adds the step progress in the footer of the shepherd tooltip
*/
const appendProgressFunc = function () {
// get all the footers that are available in the DOM
const footers = Array.from(document.querySelectorAll('.shepherd-footer'));
// get the last footer since Shepherd always puts the active one at the end
const footer = footers[footers.length - 1];
// generate steps html element
const progress = document.createElement('span');
progress.className = 'shepherd-progress';
progress.innerText = `${this.shepherdTour.currentStep.options.count}/${stepTotal}`;
// insert into the footer before the first button
footer.insertBefore(progress, footer.querySelector('.shepherd-button'));
};
let stepTotal = 0;
const steps = originalSteps.map((step) => {
let showProgress = () => {
//
};
if (step.attachToOptions?.skipFromStepCount === true) {
showProgress = function () {
return;
};
}
else if (step.attachToOptions?.skipFromStepCount === undefined ||
step.attachToOptions?.skipFromStepCount === false) {
step.count = ++stepTotal;
showProgress = appendProgressFunc.bind(this);
}
return Object.assign({}, step, {
when: {
show: showProgress,
},
});
});
const finishButton = {
text: finishLabel,
action: this['finish'].bind(this),
classes: MAT_BUTTON,
};
const voidButton = {
text: '',
action() {
return;
},
classes: MAT_BUTTON_INVISIBLE,
};
// listen to the destroyed event to clean up all the streams
this._destroyedEvent$.pipe(first()).subscribe(() => {
backEvent$.complete();
forwardEvent$.complete();
destroyedEvent$.next();
destroyedEvent$.complete();
});
const totalSteps = steps.length;
steps.forEach((step, index) => {
// create buttons specific for the step
// this is done to create more control on events
const nextButton = {
text: 'chevron_right',
action: () => {
// intercept the next action and trigger event
forwardEvent$.next();
this.shepherdTour.next();
},
classes: MAT_ICON_BUTTON,
};
const backButton = {
text: 'chevron_left',
action: () => {
// intercept the back action and trigger event
backEvent$.next();
_backFlow = true;
// check if 'goBackTo' is set to jump to a particular step, else just go back
if (step.attachToOptions && step.attachToOptions.goBackTo) {
this.shepherdTour.show(step.attachToOptions.goBackTo, false);
}
else {
this.shepherdTour.back();
}
},
classes: step.advanceOnOptions?.allowGoBack === false
? MAT_BUTTON_INVISIBLE
: MAT_ICON_BUTTON,
};
// check if highlight was provided for the step, else fallback into shepherds usage
step.highlightClass =
step.attachToOptions && step.attachToOptions.highlight
? 'shepherd-highlight'
: step.highlightClass;
// Adding buttons in the steps if no buttons are defined
if (!step.buttons || step.buttons.length === 0) {
if (index === 0) {
// first step
step.buttons = [nextButton];
}
else if (index === totalSteps - 1) {
// last step
step.buttons = [backButton, finishButton];
}
else {
step.buttons = [backButton, nextButton];
}
}
// checks "advanceOn" to override listeners
let advanceOn = step.advanceOn;
// remove the shepherd "advanceOn" infavor of ours if the event is part of our list
if ((typeof advanceOn === 'object' &&
!Array.isArray(advanceOn) &&
advanceOn.event &&
overriddenEvents.indexOf(advanceOn.event.split('.')[0]) > -1) ||
advanceOn instanceof Array) {
step.advanceOn = undefined;
step.buttons =
step.advanceOnOptions && step.advanceOnOptions.allowGoBack
? [backButton, voidButton]
: [voidButton];
}
// adds a default beforeShowPromise function
step.beforeShowPromise = () => {
return new Promise((resolve) => {
const additionalCapabilitiesSetup = () => {
if (advanceOn && !step.advanceOn) {
if (!Array.isArray(advanceOn)) {
advanceOn = [advanceOn];
}
const advanceArr$ = [];
advanceOn.forEach((_) => {
const advanceEvent$ = new Subject();
advanceArr$.push(advanceEvent$);
// we start a timer of attempts to find an element in the dom
this._bindEvent(_, step.advanceOnOptions, advanceEvent$, destroyedEvent$);
});
const advanceSubs = forkJoin(...advanceArr$)
.pipe(takeUntil(merge(destroyedEvent$, backEvent$)))
.subscribe(() => {
// check if we need to advance to a specific step, else advance to next step
if (step.advanceOnOptions && step.advanceOnOptions.jumpTo) {
this.shepherdTour.show(step.advanceOnOptions.jumpTo);
}
else {
this.shepherdTour.next();
}
forwardEvent$.next();
advanceSubs.unsubscribe();
});
}
// if abortOn was passed on the step, we bind the event and execute complete
if (step.abortOn) {
const abortArr$ = [];
step.abortOn.forEach((abortOn) => {
const abortEvent$ = new Subject();
abortArr$.push(abortEvent$);
this._bindEvent(abortOn, undefined, abortEvent$, destroyedEvent$);
});
const abortSubs = merge(...abortArr$)
.pipe(takeUntil(merge(destroyedEvent$, backEvent$, forwardEvent$)))
.subscribe(() => {
this.shepherdTour.complete();
abortSubs.unsubscribe();
});
}
};
const _stopTimer$ = new Subject();
const _retriesReached$ = new Subject();
const _retryAttempts$ = new BehaviorSubject(-1);
let id;
// checks if "attachTo" is a string or an object to get the id of an element
if (typeof step.attachTo === 'string') {
id = step.attachTo;
}
else if (typeof step.attachTo === 'object' &&
typeof step.attachTo.element === 'string') {
id = step.attachTo.element;
}
// if we have an id as a string in either case, we use it (we ignore it if its HTMLElement)
if (id) {
// if current step is the first step of the tour, we set the buttons to be only "next"
// we had to use `any` since the tour doesnt expose the steps in any fashion nor a way to check if we have modified them at all
if (this.shepherdTour.getCurrentStep() ===
this.shepherdTour.steps[0]) {
this.shepherdTour.getCurrentStep()?.updateStepOptions({
buttons: originalSteps[index].advanceOn
? [voidButton]
: [nextButton],
});
}
// register to the attempts observable to notify deeveloper when number has been reached
_retryAttempts$
.pipe(skip(1), skipWhile((val) => {
if (step.attachToOptions &&
step.attachToOptions.retries !== undefined) {
return val < step.attachToOptions.retries;
}
return val < SHEPHERD_DEFAULT_FIND_ATTEMPTS;
}), takeUntil(merge(_stopTimer$.asObservable(), destroyedEvent$)))
.subscribe((attempts) => {
_retriesReached$.next(1);
_retriesReached$.complete();
// if attempts have been reached, we check "skipIfNotFound" to move on to the next step
if (step.attachToOptions &&
step.attachToOptions.skipIfNotFound) {
// if we get to this step coming back from a step and it wasnt found
// then we either check if its the first step and try going forward
// or we keep going back until we find a step that actually exists
if (_backFlow) {
if (this.shepherdTour.steps.indexOf(this.shepherdTour.getCurrentStep()) === 0) {
this.shepherdTour.next();
}
else {
this.shepherdTour.back();
}
_backFlow = false;
}
else {
// destroys current step if we need to skip it to remove it from the tour
const currentStep = this.shepherdTour.getCurrentStep();
currentStep?.destroy();
this.shepherdTour.next();
this.shepherdTour.removeStep(currentStep?.id ?? '');
}
}
else if (step.attachToOptions && step.attachToOptions.else) {
// if "skipIfNotFound" is not true, then we check if "else" has been set to jump to a specific step
this.shepherdTour.show(step.attachToOptions.else);
}
else {
// tslint:disable-next-line:no-console
console.warn(`Retries reached trying to find ${id}. Retried ${attempts} times.`);
// else we show the step regardless
resolve();
}
});
// we start a timer of attempts to find an element in the dom
timer((step.attachToOptions && step.attachToOptions.timeBeforeShow) ||
SHEPHERD_DEFAULT_FIND_TIME_BEFORE_SHOW, (step.attachToOptions && step.attachToOptions.interval) ||
SHEPHERD_DEFAULT_FIND_INTERVAL)
.pipe(
// the timer will continue either until we find the element or the number of attempts has been reached
takeUntil(merge(_stopTimer$, _retriesReached$, destroyedEvent$)))
.subscribe(() => {
const element = document.querySelector(id ?? '');
// if the element has been found, we stop the timer and resolve the promise
if (element) {
_stopTimer$.next();
_stopTimer$.complete();
additionalCapabilitiesSetup();
resolve();
}
else {
_retryAttempts$.next(_retryAttempts$.value + 1);
}
});
// stop find interval if user stops the tour
destroyedEvent$.subscribe(() => {
_stopTimer$.next();
_stopTimer$.complete();
_retriesReached$.next(1);
_retriesReached$.complete();
});
}
else {
// resolve observable until the timeBeforeShow has passsed or use default
timer((step.attachToOptions && step.attachToOptions.timeBeforeShow) ||
SHEPHERD_DEFAULT_FIND_TIME_BEFORE_SHOW)
.pipe(takeUntil(merge(destroyedEvent$)))
.subscribe(() => {
resolve();
});
}
});
};
});
return steps;
}
_bindEvent(eventOn, eventOnOptions, event$, destroyedEvent$) {
const selector = eventOn.selector ?? '';
const event = eventOn.event;
// we start a timer of attempts to find an element in the dom
const timerSubs = timer((eventOnOptions && eventOnOptions.timeBeforeShow) ||
SHEPHERD_DEFAULT_FIND_TIME_BEFORE_SHOW, (eventOnOptions && eventOnOptions.interval) ||
SHEPHERD_DEFAULT_FIND_INTERVAL)
.pipe(takeUntil(destroyedEvent$))
.subscribe(() => {
const element = document.querySelector(selector);
// if the element has been found, we stop the timer and resolve the promise
if (element) {
timerSubs.unsubscribe();
if (event === ITourEvent.added) {
// if event is "Added" trigger a soon as this is attached.
event$.next();
event$.complete();
}
else if (event === ITourEvent.click ||
event === ITourEvent.pointerover ||
(event && event.indexOf(ITourEvent.keyup) > -1)) {
// we use normal listeners for mouseevents
const mainEvent = event?.split('.')[0];
const subEvent = event?.split('.')[1];
fromEvent(element, mainEvent)
.pipe(filter(($event) => {
// only trigger if the event is a keyboard event and part of out list
if ($event instanceof KeyboardEvent) {
if (keyEvents.get($event.keyCode) === subEvent) {
return true;
}
return false;
}
else {
return true;
}
}), takeUntil(merge(event$.asObservable(), destroyedEvent$)))
.subscribe(() => {
event$.next();
event$.complete();
});
}
else if (event === ITourEvent.removed) {
// and we will use MutationObserver for DOM events
const observer = new MutationObserver(() => {
if (!document.body.contains(element)) {
event$.next();
event$.complete();
observer.disconnect();
}
});
// stop listenining if tour is closed
destroyedEvent$.subscribe(() => {
observer.disconnect();
});
// observe for any DOM interaction in the element
observer.observe(element, {
childList: true,
subtree: true,
attributes: true,
});
}
}
});
}
}
/**
* Router enabled Shepherd tour
*/
var TourEvents;
(function (TourEvents) {
TourEvents["complete"] = "complete";
TourEvents["cancel"] = "cancel";
TourEvents["hide"] = "hide";
TourEvents["show"] = "show";
TourEvents["start"] = "start";
TourEvents["active"] = "active";
TourEvents["inactive"] = "inactive";
})(TourEvents || (TourEvents = {}));
class CovalentGuidedTourService extends CovalentGuidedTour {
_router;
_route;
_httpClient;
_toursMap = new Map();
_tourStepURLs = new Map();
constructor(_router, _route, _httpClient) {
super();
this._router = _router;
this._route = _route;
this._httpClient = _httpClient;
_router.events
.pipe(filter((event) => event instanceof NavigationStart &&
event.navigationTrigger === 'popstate'))
.subscribe(() => {
if (this.shepherdTour.isActive()) {
this.shepherdTour.cancel();
}
});
}
tourEvent$(str) {
return fromEvent(this.shepherdTour, str);
}
async registerTour(tourName, tour) {
const guidedTour = typeof tour === 'string' ? await this._loadTour(tour) : tour;
this._toursMap.set(tourName, guidedTour);
}
startTour(tourName) {
const guidedTour = this._getTour(tourName);
this.finish();
if (guidedTour && guidedTour.steps && guidedTour.steps.length) {
// remove steps from tour since we need to preprocess them first
this.newTour(Object.assign({}, guidedTour, { steps: undefined }));
const tourInstance = this.shepherdTour.addSteps(this._configureRoutesForSteps(this._prepareTour(guidedTour.steps, guidedTour.finishButtonText)));
// init route transition if step URL is different then the current location.
this.tourEvent$(TourEvents.show).subscribe((tourEvent) => {
const currentURL = this._router.url.split(/[?#]/)[0];
const { step: { id, options }, } = tourEvent;
if (this._tourStepURLs.has(id)) {
const stepRoute = this._tourStepURLs.get(id);
if (stepRoute !== currentURL) {
this._router.navigate([stepRoute]);
}
}
else {
if (options && options.routing) {
this._tourStepURLs.set(id, options.routing.route);
}
else {
this._tourStepURLs.set(id, currentURL);
}
}
});
this.start();
return tourInstance;
}
else {
// tslint:disable-next-line:no-console
console.warn(`Tour ${tourName} does not exist. Please try another tour.`);
return undefined;
}
}
// Finds the right registered tour by using queryParams
// finishes any other tour and starts the new one.
initializeOnQueryParams(queryParam = 'tour') {
return this._route.queryParamMap.pipe(debounceTime(100), tap((params) => {
const tourParam = params.get(queryParam);
if (tourParam) {
this.startTour(tourParam);
// get current search parameters
const searchParams = new URLSearchParams(window.location.search);
// delete tour queryParam
searchParams.delete(queryParam);
// build new URL string without it
let url = window.location.protocol +
'//' +
window.location.host +
window.location.pathname;
if (searchParams.toString()) {
url += '?' + searchParams.toString();
}
// replace state in history without triggering a navigation
window.history.replaceState({ path: url }, '', url);
}
}));
}
setNextBtnDisability(stepId, isDisabled) {
if (this.shepherdTour.getById(stepId)) {
const stepOptions = this.shepherdTour.getById(stepId)
.options;
stepOptions.buttons?.forEach((button) => {
if (button.text === 'chevron_right') {
button.disabled = isDisabled;
}
});
this.shepherdTour.getById(stepId)?.updateStepOptions(stepOptions);
}
}
async _loadTour(tourUrl) {
const request = this._httpClient.get(tourUrl);
try {
return await request
.pipe(map((resultSet) => {
return JSON.parse(JSON.stringify(resultSet));
}))
.toPromise();
}
catch {
return undefined;
}
}
_getTour(key) {
return this._toursMap.get(key);
}
_configureRoutesForSteps(routedSteps) {
routedSteps.forEach((step) => {
if (step.routing) {
const route = step.routing.route;
// if there is a beforeShowPromise, then we save it and call it after the navigation
if (step.beforeShowPromise) {
const beforeShowPromise = step.beforeShowPromise;
step.beforeShowPromise = () => {
return this._router
.navigate([route], step.routing?.extras)
.then(() => {
return beforeShowPromise();
});
};
}
else {
step.beforeShowPromise = () => this._router.navigate([route]);
}
}
});
return routedSteps;
}
static ɵfac = function CovalentGuidedTourService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || CovalentGuidedTourService)(i0.ɵɵinject(i1.Router), i0.ɵɵinject(i1.ActivatedRoute), i0.ɵɵinject(i2.HttpClient)); };
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: CovalentGuidedTourService, factory: CovalentGuidedTourService.ɵfac });
}
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(CovalentGuidedTourService, [{
type: Injectable
}], () => [{ type: i1.Router }, { type: i1.ActivatedRoute }, { type: i2.HttpClient }], null); })();
class CovalentGuidedTourModule {
static ɵfac = function CovalentGuidedTourModule_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || CovalentGuidedTourModule)(); };
static ɵmod = /*@__PURE__*/ i0.ɵɵdefineNgModule({ type: CovalentGuidedTourModule });
static ɵinj = /*@__PURE__*/ i0.ɵɵdefineInjector({ providers: [CovalentGuidedTourService], imports: [CommonModule] });
}
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(CovalentGuidedTourModule, [{
type: NgModule,
args: [{
imports: [CommonModule],
providers: [CovalentGuidedTourService],
declarations: [],
exports: [],
}]
}], null, null); })();
(function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵɵsetNgModuleScope(CovalentGuidedTourModule, { imports: [CommonModule] }); })();
/**
* Generated bundle index. Do not edit.
*/
export { CovalentGuidedTour, CovalentGuidedTourModule, CovalentGuidedTourService, ITourEvent, TourEvents };
//# sourceMappingURL=covalent-guided-tour.mjs.map