UNPKG

@covalent/guided-tour

Version:
627 lines (621 loc) 28.9 kB
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