Proyectos de Subversion Moodle

Rev

Ir a la última revisión | | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * A user tour.
18
 *
19
 * @module tool_usertours/tour
20
 * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
/**
25
 * A list of steps.
26
 *
27
 * @typedef {Object[]} StepList
28
 * @property {Number} stepId The id of the step in the database
29
 * @property {Number} position The position of the step within the tour (zero-indexed)
30
 */
31
 
32
import $ from 'jquery';
33
import * as Aria from 'core/aria';
34
import Popper from 'core/popper';
35
import {dispatchEvent} from 'core/event_dispatcher';
36
import {eventTypes} from './events';
37
import {getString} from 'core/str';
38
import {prefetchStrings} from 'core/prefetch';
39
import {notifyFilterContentUpdated} from 'core/event';
40
 
41
/**
42
 * The minimum spacing for tour step to display.
43
 *
44
 * @private
45
 * @constant
46
 * @type {number}
47
 */
48
const MINSPACING = 10;
49
 
50
/**
51
 * A user tour.
52
 *
53
 * @class tool_usertours/tour
54
 * @property {boolean} tourRunning Whether the tour is currently running.
55
 */
56
const Tour = class {
57
    tourRunning = false;
58
 
59
    /**
60
     * @param   {object}    config  The configuration object.
61
     */
62
    constructor(config) {
63
        this.init(config);
64
    }
65
 
66
    /**
67
     * Initialise the tour.
68
     *
69
     * @method  init
70
     * @param   {Object}    config  The configuration object.
71
     * @chainable
72
     * @return {Object} this.
73
     */
74
    init(config) {
75
        // Unset all handlers.
76
        this.eventHandlers = {};
77
 
78
        // Reset the current tour states.
79
        this.reset();
80
 
81
        // Store the initial configuration.
82
        this.originalConfiguration = config || {};
83
 
84
        // Apply configuration.
85
        this.configure.apply(this, arguments);
86
 
87
        // Unset recalculate state.
88
        this.possitionNeedToBeRecalculated = false;
89
 
90
        // Unset recalculate count.
91
        this.recalculatedNo = 0;
92
 
93
        try {
94
            this.storage = window.sessionStorage;
95
            this.storageKey = 'tourstate_' + this.tourName;
96
        } catch (e) {
97
            this.storage = false;
98
            this.storageKey = '';
99
        }
100
 
101
        prefetchStrings('tool_usertours', [
102
            'nextstep_sequence',
103
            'skip_tour'
104
        ]);
105
 
106
        return this;
107
    }
108
 
109
    /**
110
     * Reset the current tour state.
111
     *
112
     * @method  reset
113
     * @chainable
114
     * @return {Object} this.
115
     */
116
    reset() {
117
        // Hide the current step.
118
        this.hide();
119
 
120
        // Unset all handlers.
121
        this.eventHandlers = [];
122
 
123
        // Unset all listeners.
124
        this.resetStepListeners();
125
 
126
        // Unset the original configuration.
127
        this.originalConfiguration = {};
128
 
129
        // Reset the current step number and list of steps.
130
        this.steps = [];
131
 
132
        // Reset the current step number.
133
        this.currentStepNumber = 0;
134
 
135
        return this;
136
    }
137
 
138
    /**
139
     * Prepare tour configuration.
140
     *
141
     * @method  configure
142
     * @param {Object} config The configuration object.
143
     * @chainable
144
     * @return {Object} this.
145
     */
146
    configure(config) {
147
        if (typeof config === 'object') {
148
            // Tour name.
149
            if (typeof config.tourName !== 'undefined') {
150
                this.tourName = config.tourName;
151
            }
152
 
153
            // Set up eventHandlers.
154
            if (config.eventHandlers) {
155
                for (let eventName in config.eventHandlers) {
156
                    config.eventHandlers[eventName].forEach(function(handler) {
157
                        this.addEventHandler(eventName, handler);
158
                    }, this);
159
                }
160
            }
161
 
162
            // Reset the step configuration.
163
            this.resetStepDefaults(true);
164
 
165
            // Configure the steps.
166
            if (typeof config.steps === 'object') {
167
                this.steps = config.steps;
168
            }
169
 
170
            if (typeof config.template !== 'undefined') {
171
                this.templateContent = config.template;
172
            }
173
        }
174
 
175
        // Check that we have enough to start the tour.
176
        this.checkMinimumRequirements();
177
 
178
        return this;
179
    }
180
 
181
    /**
182
     * Check that the configuration meets the minimum requirements.
183
     *
184
     * @method  checkMinimumRequirements
185
     */
186
    checkMinimumRequirements() {
187
        // Need a tourName.
188
        if (!this.tourName) {
189
            throw new Error("Tour Name required");
190
        }
191
 
192
        // Need a minimum of one step.
193
        if (!this.steps || !this.steps.length) {
194
            throw new Error("Steps must be specified");
195
        }
196
    }
197
 
198
    /**
199
     * Reset step default configuration.
200
     *
201
     * @method  resetStepDefaults
202
     * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
203
     * @chainable
204
     * @return {Object} this.
205
     */
206
    resetStepDefaults(loadOriginalConfiguration) {
207
        if (typeof loadOriginalConfiguration === 'undefined') {
208
            loadOriginalConfiguration = true;
209
        }
210
 
211
        this.stepDefaults = {};
212
        if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
213
            this.setStepDefaults({});
214
        } else {
215
            this.setStepDefaults(this.originalConfiguration.stepDefaults);
216
        }
217
 
218
        return this;
219
    }
220
 
221
    /**
222
     * Set the step defaults.
223
     *
224
     * @method  setStepDefaults
225
     * @param   {Object}    stepDefaults                The step defaults to apply to all steps
226
     * @chainable
227
     * @return {Object} this.
228
     */
229
    setStepDefaults(stepDefaults) {
230
        if (!this.stepDefaults) {
231
            this.stepDefaults = {};
232
        }
233
        $.extend(
234
            this.stepDefaults,
235
            {
236
                element:        '',
237
                placement:      'top',
238
                delay:          0,
239
                moveOnClick:    false,
240
                moveAfterTime:  0,
241
                orphan:         false,
242
                direction:      1,
243
            },
244
            stepDefaults
245
        );
246
 
247
        return this;
248
    }
249
 
250
    /**
251
     * Retrieve the current step number.
252
     *
253
     * @method  getCurrentStepNumber
254
     * @return  {Number}                   The current step number
255
     */
256
    getCurrentStepNumber() {
257
        return parseInt(this.currentStepNumber, 10);
258
    }
259
 
260
    /**
261
     * Store the current step number.
262
     *
263
     * @method  setCurrentStepNumber
264
     * @param   {Number}   stepNumber      The current step number
265
     * @chainable
266
     */
267
    setCurrentStepNumber(stepNumber) {
268
        this.currentStepNumber = stepNumber;
269
        if (this.storage) {
270
            try {
271
                this.storage.setItem(this.storageKey, stepNumber);
272
            } catch (e) {
273
                if (e.code === DOMException.QUOTA_EXCEEDED_ERR) {
274
                    this.storage.removeItem(this.storageKey);
275
                }
276
            }
277
        }
278
    }
279
 
280
    /**
281
     * Get the next step number after the currently displayed step.
282
     *
283
     * @method  getNextStepNumber
284
     * @param   {Number}   stepNumber      The current step number
285
     * @return  {Number}    The next step number to display
286
     */
287
    getNextStepNumber(stepNumber) {
288
        if (typeof stepNumber === 'undefined') {
289
            stepNumber = this.getCurrentStepNumber();
290
        }
291
        let nextStepNumber = stepNumber + 1;
292
 
293
        // Keep checking the remaining steps.
294
        while (nextStepNumber <= this.steps.length) {
295
            if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
296
                return nextStepNumber;
297
            }
298
            nextStepNumber++;
299
        }
300
 
301
        return null;
302
    }
303
 
304
    /**
305
     * Get the previous step number before the currently displayed step.
306
     *
307
     * @method  getPreviousStepNumber
308
     * @param   {Number}   stepNumber      The current step number
309
     * @return  {Number}    The previous step number to display
310
     */
311
    getPreviousStepNumber(stepNumber) {
312
        if (typeof stepNumber === 'undefined') {
313
            stepNumber = this.getCurrentStepNumber();
314
        }
315
        let previousStepNumber = stepNumber - 1;
316
 
317
        // Keep checking the remaining steps.
318
        while (previousStepNumber >= 0) {
319
            if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
320
                return previousStepNumber;
321
            }
322
            previousStepNumber--;
323
        }
324
 
325
        return null;
326
    }
327
 
328
    /**
329
     * Is the step the final step number?
330
     *
331
     * @method  isLastStep
332
     * @param   {Number}   stepNumber  Step number to test
333
     * @return  {Boolean}               Whether the step is the final step
334
     */
335
    isLastStep(stepNumber) {
336
        let nextStepNumber = this.getNextStepNumber(stepNumber);
337
 
338
        return nextStepNumber === null;
339
    }
340
 
341
    /**
342
     * Is this step potentially visible?
343
     *
344
     * @method  isStepPotentiallyVisible
345
     * @param   {Object}    stepConfig      The step configuration to normalise
346
     * @return  {Boolean}               Whether the step is the potentially visible
347
     */
348
    isStepPotentiallyVisible(stepConfig) {
349
        if (!stepConfig) {
350
            // Without step config, there can be no step.
351
            return false;
352
        }
353
 
354
        if (this.isStepActuallyVisible(stepConfig)) {
355
            // If it is actually visible, it is already potentially visible.
356
            return true;
357
        }
358
 
359
        if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
360
            // Orphan steps have no target. They are always visible.
361
            return true;
362
        }
363
 
364
        if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
365
            // Only return true if the activated has not been used yet.
366
            return true;
367
        }
368
 
369
        // Not theoretically, or actually visible.
370
        return false;
371
    }
372
 
373
    /**
374
     * Get potentially visible steps in a tour.
375
     *
376
     * @returns {StepList} A list of ordered steps
377
     */
378
    getPotentiallyVisibleSteps() {
379
        let position = 1;
380
        let result = [];
381
        // Checking the total steps.
382
        for (let stepNumber = 0; stepNumber < this.steps.length; stepNumber++) {
383
            const stepConfig = this.getStepConfig(stepNumber);
384
            if (this.isStepPotentiallyVisible(stepConfig)) {
385
                result[stepNumber] = {stepId: stepConfig.stepid, position: position};
386
                position++;
387
            }
388
        }
389
 
390
        return result;
391
    }
392
 
393
    /**
394
     * Is this step actually visible?
395
     *
396
     * @method  isStepActuallyVisible
397
     * @param   {Object}    stepConfig      The step configuration to normalise
398
     * @return  {Boolean}               Whether the step is actually visible
399
     */
400
    isStepActuallyVisible(stepConfig) {
401
        if (!stepConfig) {
402
            // Without step config, there can be no step.
403
            return false;
404
        }
405
 
406
        // Check if the CSS styles are allowed on the browser or not.
407
        if (!this.isCSSAllowed()) {
408
            return false;
409
        }
410
 
411
        let target = this.getStepTarget(stepConfig);
412
        if (target && target.length && target.is(':visible')) {
413
            // Without a target, there can be no step.
414
            return !!target.length;
415
        }
416
 
417
        return false;
418
    }
419
 
420
    /**
421
     * Is the browser actually allow CSS styles?
422
     *
423
     * @returns {boolean} True if the browser is allowing CSS styles
424
     */
425
    isCSSAllowed() {
426
        const testCSSElement = document.createElement('div');
427
        testCSSElement.classList.add('hide');
428
        document.body.appendChild(testCSSElement);
429
        const styles = window.getComputedStyle(testCSSElement);
430
        const isAllowed = styles.display === 'none';
431
        testCSSElement.remove();
432
 
433
        return isAllowed;
434
    }
435
 
436
    /**
437
     * Go to the next step in the tour.
438
     *
439
     * @method  next
440
     * @chainable
441
     * @return {Object} this.
442
     */
443
    next() {
444
        return this.gotoStep(this.getNextStepNumber());
445
    }
446
 
447
    /**
448
     * Go to the previous step in the tour.
449
     *
450
     * @method  previous
451
     * @chainable
452
     * @return {Object} this.
453
     */
454
    previous() {
455
        return this.gotoStep(this.getPreviousStepNumber(), -1);
456
    }
457
 
458
    /**
459
     * Go to the specified step in the tour.
460
     *
461
     * @method  gotoStep
462
     * @param   {Number}   stepNumber     The step number to display
463
     * @param   {Number}   direction      Next or previous step
464
     * @chainable
465
     * @return {Object} this.
466
     * @fires tool_usertours/stepRender
467
     * @fires tool_usertours/stepRendered
468
     * @fires tool_usertours/stepHide
469
     * @fires tool_usertours/stepHidden
470
     */
471
    gotoStep(stepNumber, direction) {
472
        if (stepNumber < 0) {
473
            return this.endTour();
474
        }
475
 
476
        let stepConfig = this.getStepConfig(stepNumber);
477
        if (stepConfig === null) {
478
            return this.endTour();
479
        }
480
 
481
        return this._gotoStep(stepConfig, direction);
482
    }
483
 
484
    _gotoStep(stepConfig, direction) {
485
        if (!stepConfig) {
486
            return this.endTour();
487
        }
488
 
489
        if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
490
            stepConfig.delayed = true;
491
            window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
492
 
493
            return this;
494
        } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
495
            let fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
496
            return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
497
        }
498
 
499
        this.hide();
500
 
501
        const stepRenderEvent = this.dispatchEvent(eventTypes.stepRender, {stepConfig}, true);
502
        if (!stepRenderEvent.defaultPrevented) {
503
            this.renderStep(stepConfig);
504
            this.dispatchEvent(eventTypes.stepRendered, {stepConfig});
505
        }
506
 
507
        return this;
508
    }
509
 
510
    /**
511
     * Fetch the normalised step configuration for the specified step number.
512
     *
513
     * @method  getStepConfig
514
     * @param   {Number}   stepNumber      The step number to fetch configuration for
515
     * @return  {Object}                    The step configuration
516
     */
517
    getStepConfig(stepNumber) {
518
        if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
519
            return null;
520
        }
521
 
522
        // Normalise the step configuration.
523
        let stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
524
 
525
        // Add the stepNumber to the stepConfig.
526
        stepConfig = $.extend(stepConfig, {stepNumber: stepNumber});
527
 
528
        return stepConfig;
529
    }
530
 
531
    /**
532
     * Normalise the supplied step configuration.
533
     *
534
     * @method  normalizeStepConfig
535
     * @param   {Object}    stepConfig      The step configuration to normalise
536
     * @return  {Object}                    The normalised step configuration
537
     */
538
    normalizeStepConfig(stepConfig) {
539
 
540
        if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
541
            stepConfig.moveAfterClick = stepConfig.reflex;
542
        }
543
 
544
        if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
545
            stepConfig.target = stepConfig.element;
546
        }
547
 
548
        if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
549
            stepConfig.body = stepConfig.content;
550
        }
551
 
552
        stepConfig = $.extend({}, this.stepDefaults, stepConfig);
553
 
554
        stepConfig = $.extend({}, {
555
            attachTo: stepConfig.target,
556
            attachPoint: 'after',
557
        }, stepConfig);
558
 
559
        if (stepConfig.attachTo) {
560
            stepConfig.attachTo = $(stepConfig.attachTo).first();
561
        }
562
 
563
        return stepConfig;
564
    }
565
 
566
    /**
567
     * Fetch the actual step target from the selector.
568
     *
569
     * This should not be called until after any delay has completed.
570
     *
571
     * @method  getStepTarget
572
     * @param   {Object}    stepConfig      The step configuration
573
     * @return  {$}
574
     */
575
    getStepTarget(stepConfig) {
576
        if (stepConfig.target) {
577
            return $(stepConfig.target);
578
        }
579
 
580
        return null;
581
    }
582
 
583
    /**
584
     * Fire any event handlers for the specified event.
585
     *
586
     * @param {String} eventName The name of the event
587
     * @param {Object} [detail={}] Any additional details to pass into the eveent
588
     * @param {Boolean} [cancelable=false] Whether preventDefault() can be called
589
     * @returns {CustomEvent}
590
     */
591
    dispatchEvent(
592
        eventName,
593
        detail = {},
594
        cancelable = false
595
    ) {
596
        return dispatchEvent(eventName, {
597
            // Add the tour to the detail.
598
            tour: this,
599
            ...detail,
600
        }, document, {
601
            cancelable,
602
        });
603
    }
604
 
605
    /**
606
     * @method addEventHandler
607
     * @param  {string}      eventName       The name of the event to listen for
608
     * @param  {function}    handler         The event handler to call
609
     * @return {Object} this.
610
     */
611
    addEventHandler(eventName, handler) {
612
        if (typeof this.eventHandlers[eventName] === 'undefined') {
613
            this.eventHandlers[eventName] = [];
614
        }
615
 
616
        this.eventHandlers[eventName].push(handler);
617
 
618
        return this;
619
    }
620
 
621
    /**
622
     * Process listeners for the step being shown.
623
     *
624
     * @method  processStepListeners
625
     * @param   {object}    stepConfig      The configuration for the step
626
     * @chainable
627
     * @return {Object} this.
628
     */
629
    processStepListeners(stepConfig) {
630
        this.listeners.push(
631
        // Next button.
632
        {
633
            node: this.currentStepNode,
634
            args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
635
        },
636
 
637
        // Close and end tour buttons.
638
        {
639
            node: this.currentStepNode,
640
            args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
641
        },
642
 
643
        // Click backdrop and hide tour.
644
        {
645
            node: $('[data-flexitour="backdrop"]'),
646
            args: ['click', $.proxy(this.hide, this)]
647
        },
648
 
649
        // Keypresses.
650
        {
651
            node: $('body'),
652
            args: ['keydown', $.proxy(this.handleKeyDown, this)]
653
        });
654
 
655
        if (stepConfig.moveOnClick) {
656
            var targetNode = this.getStepTarget(stepConfig);
657
            this.listeners.push({
658
                node: targetNode,
659
                args: ['click', $.proxy(function(e) {
660
                    if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
661
                        // Ignore clicks when they are in the flexitour.
662
                        window.setTimeout($.proxy(this.next, this), 500);
663
                    }
664
                }, this)]
665
            });
666
        }
667
 
668
        this.listeners.forEach(function(listener) {
669
            listener.node.on.apply(listener.node, listener.args);
670
        });
671
 
672
        return this;
673
    }
674
 
675
    /**
676
     * Reset step listeners.
677
     *
678
     * @method  resetStepListeners
679
     * @chainable
680
     * @return {Object} this.
681
     */
682
    resetStepListeners() {
683
        // Stop listening to all external handlers.
684
        if (this.listeners) {
685
            this.listeners.forEach(function(listener) {
686
                listener.node.off.apply(listener.node, listener.args);
687
            });
688
        }
689
        this.listeners = [];
690
 
691
        return this;
692
    }
693
 
694
    /**
695
     * The standard step renderer.
696
     *
697
     * @method  renderStep
698
     * @param   {Object}    stepConfig      The step configuration of the step
699
     * @chainable
700
     * @return {Object} this.
701
     */
702
    renderStep(stepConfig) {
703
        // Store the current step configuration for later.
704
        this.currentStepConfig = stepConfig;
705
        this.setCurrentStepNumber(stepConfig.stepNumber);
706
 
707
        // Fetch the template and convert it to a $ object.
708
        let template = $(this.getTemplateContent());
709
 
710
        // Title.
711
        template.find('[data-placeholder="title"]')
712
            .html(stepConfig.title);
713
 
714
        // Body.
715
        template.find('[data-placeholder="body"]')
716
            .html(stepConfig.body);
717
 
718
        // Buttons.
719
        const nextBtn = template.find('[data-role="next"]');
720
        const endBtn = template.find('[data-role="end"]');
721
 
722
        // Is this the final step?
723
        if (this.isLastStep(stepConfig.stepNumber)) {
724
            nextBtn.hide();
725
            endBtn.removeClass("btn-secondary").addClass("btn-primary");
726
        } else {
727
            nextBtn.prop('disabled', false);
728
            // Use Skip tour label for the End tour button.
729
            getString('skip_tour', 'tool_usertours').then(value => {
730
                endBtn.html(value);
731
                return;
732
            }).catch();
733
        }
734
 
735
        nextBtn.attr('role', 'button');
736
        endBtn.attr('role', 'button');
737
 
738
        if (this.originalConfiguration.displaystepnumbers) {
739
            const stepsPotentiallyVisible = this.getPotentiallyVisibleSteps();
740
            const totalStepsPotentiallyVisible = stepsPotentiallyVisible.length;
741
            const position = stepsPotentiallyVisible[stepConfig.stepNumber].position;
742
            if (totalStepsPotentiallyVisible > 1) {
743
                // Change the label of the Next button to include the sequence.
744
                getString('nextstep_sequence', 'tool_usertours',
745
                    {position: position, total: totalStepsPotentiallyVisible}).then(value => {
746
                    nextBtn.html(value);
747
                    return;
748
                }).catch();
749
            }
750
        }
751
 
752
        // Replace the template with the updated version.
753
        stepConfig.template = template;
754
 
755
        // Add to the page.
756
        this.addStepToPage(stepConfig);
757
 
758
        // Process step listeners after adding to the page.
759
        // This uses the currentNode.
760
        this.processStepListeners(stepConfig);
761
 
762
        return this;
763
    }
764
 
765
    /**
766
     * Getter for the template content.
767
     *
768
     * @method  getTemplateContent
769
     * @return  {$}
770
     */
771
    getTemplateContent() {
772
        return $(this.templateContent).clone();
773
    }
774
 
775
    /**
776
     * Helper to add a step to the page.
777
     *
778
     * @method  addStepToPage
779
     * @param   {Object}    stepConfig      The step configuration of the step
780
     * @chainable
781
     * @return {Object} this.
782
     */
783
    addStepToPage(stepConfig) {
784
        // Create the stepNode from the template data.
785
        let currentStepNode = $('<span data-flexitour="container"></span>')
786
            .html(stepConfig.template)
787
            .hide();
788
        // Trigger the Moodle filters.
789
        notifyFilterContentUpdated(currentStepNode);
790
 
791
        // The scroll animation occurs on the body or html.
792
        let animationTarget = $('body, html')
793
            .stop(true, true);
794
 
795
        if (this.isStepActuallyVisible(stepConfig)) {
796
            let targetNode = this.getStepTarget(stepConfig);
797
 
798
            if (targetNode.parents('[data-usertour="scroller"]').length) {
799
                animationTarget = targetNode.parents('[data-usertour="scroller"]');
800
            }
801
 
802
            targetNode.data('flexitour', 'target');
803
 
804
            let zIndex = this.calculateZIndex(targetNode);
805
            if (zIndex) {
806
                stepConfig.zIndex = zIndex + 1;
807
            }
808
 
809
            if (stepConfig.zIndex) {
810
                currentStepNode.css('zIndex', stepConfig.zIndex + 1);
811
            }
812
 
813
            // Add the backdrop.
814
            this.positionBackdrop(stepConfig);
815
 
816
            $(document.body).append(currentStepNode);
817
            this.currentStepNode = currentStepNode;
818
 
819
            // Ensure that the step node is positioned.
820
            // Some situations mean that the value is not properly calculated without this step.
821
            this.currentStepNode.css({
822
                top: 0,
823
                left: 0,
824
            });
825
 
826
            animationTarget
827
                .animate({
828
                    scrollTop: this.calculateScrollTop(stepConfig),
829
                }).promise().then(function() {
830
                        this.positionStep(stepConfig);
831
                        this.revealStep(stepConfig);
832
                        return;
833
                    }.bind(this))
834
                    .catch(function() {
835
                        // Silently fail.
836
                    });
837
 
838
        } else if (stepConfig.orphan) {
839
            stepConfig.isOrphan = true;
840
 
841
            // This will be appended to the body instead.
842
            stepConfig.attachTo = $('body').first();
843
            stepConfig.attachPoint = 'append';
844
 
845
            // Add the backdrop.
846
            this.positionBackdrop(stepConfig);
847
 
848
            // This is an orphaned step.
849
            currentStepNode.addClass('orphan');
850
 
851
            // It lives in the body.
852
            $(document.body).append(currentStepNode);
853
            this.currentStepNode = currentStepNode;
854
 
855
            this.currentStepNode.css('position', 'fixed');
856
 
857
            this.currentStepPopper = new Popper(
858
                $('body'),
859
                this.currentStepNode[0], {
860
                    removeOnDestroy: true,
861
                    placement: stepConfig.placement + '-start',
862
                    arrowElement: '[data-role="arrow"]',
863
                    // Empty the modifiers. We've already placed the step and don't want it moved.
864
                    modifiers: {
865
                        hide: {
866
                            enabled: false,
867
                        },
868
                        applyStyle: {
869
                            onLoad: null,
870
                            enabled: false,
871
                        },
872
                    },
873
                    onCreate: () => {
874
                        // First, we need to check if the step's content contains any images.
875
                        const images = this.currentStepNode.find('img');
876
                        if (images.length) {
877
                            // Images found, need to calculate the position when the image is loaded.
878
                            images.on('load', () => {
879
                                this.calculateStepPositionInPage(currentStepNode);
880
                            });
881
                        }
882
                        this.calculateStepPositionInPage(currentStepNode);
883
                    }
884
                }
885
            );
886
 
887
            this.revealStep(stepConfig);
888
        }
889
 
890
        return this;
891
    }
892
 
893
    /**
894
     * Make the given step visible.
895
     *
896
     * @method revealStep
897
     * @param {Object} stepConfig The step configuration of the step
898
     * @chainable
899
     * @return {Object} this.
900
     */
901
    revealStep(stepConfig) {
902
        // Fade the step in.
903
        this.currentStepNode.fadeIn('', $.proxy(function() {
904
                // Announce via ARIA.
905
                this.announceStep(stepConfig);
906
 
907
                // Focus on the current step Node.
908
                this.currentStepNode.focus();
909
                window.setTimeout($.proxy(function() {
910
                    // After a brief delay, focus again.
911
                    // There seems to be an issue with Jaws where it only reads the dialogue title initially.
912
                    // This second focus helps it to read the full dialogue.
913
                    if (this.currentStepNode) {
914
                        this.currentStepNode.focus();
915
                    }
916
                }, this), 100);
917
 
918
            }, this));
919
 
920
        return this;
921
    }
922
 
923
    /**
924
     * Helper to announce the step on the page.
925
     *
926
     * @method  announceStep
927
     * @param   {Object}    stepConfig      The step configuration of the step
928
     * @chainable
929
     * @return {Object} this.
930
     */
931
    announceStep(stepConfig) {
932
        // Setup the step Dialogue as per:
933
        // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
934
        // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
935
 
936
        // Generate an ID for the current step node.
937
        let stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
938
        this.currentStepNode.attr('id', stepId);
939
 
940
        let bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
941
        bodyRegion.attr('id', stepId + '-body');
942
        bodyRegion.attr('role', 'document');
943
 
944
        let headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
945
        headerRegion.attr('id', stepId + '-title');
946
        headerRegion.attr('aria-labelledby', stepId + '-body');
947
 
948
        // Generally, a modal dialog has a role of dialog.
949
        this.currentStepNode.attr('role', 'dialog');
950
        this.currentStepNode.attr('tabindex', 0);
951
        this.currentStepNode.attr('aria-labelledby', stepId + '-title');
952
        this.currentStepNode.attr('aria-describedby', stepId + '-body');
953
 
954
        // Configure ARIA attributes on the target.
955
        let target = this.getStepTarget(stepConfig);
956
        if (target) {
957
            target.data('original-tabindex', target.attr('tabindex'));
958
            if (!target.attr('tabindex')) {
959
                target.attr('tabindex', 0);
960
            }
961
 
962
            target
963
                .data('original-describedby', target.attr('aria-describedby'))
964
                .attr('aria-describedby', stepId + '-body')
965
                ;
966
        }
967
 
968
        this.accessibilityShow(stepConfig);
969
 
970
        return this;
971
    }
972
 
973
    /**
974
     * Handle key down events.
975
     *
976
     * @method  handleKeyDown
977
     * @param   {EventFacade} e
978
     */
979
    handleKeyDown(e) {
980
        let tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], ';
981
        tabbableSelector += ':input:enabled, [tabindex], button:enabled';
982
        switch (e.keyCode) {
983
            case 27:
984
                this.endTour();
985
                break;
986
 
987
            // 9 == Tab - trap focus for items with a backdrop.
988
            case 9:
989
                // Tab must be handled on key up only in this instance.
990
                (function() {
991
                    if (!this.currentStepConfig.hasBackdrop) {
992
                        // Trapping tab focus is only handled for those steps with a backdrop.
993
                        return;
994
                    }
995
 
996
                    // Find all tabbable locations.
997
                    let activeElement = $(document.activeElement);
998
                    let stepTarget = this.getStepTarget(this.currentStepConfig);
999
                    let tabbableNodes = $(tabbableSelector);
1000
                    let dialogContainer = $('span[data-flexitour="container"]');
1001
                    let currentIndex;
1002
                    // Filter out element which is not belong to target section or dialogue.
1003
                    if (stepTarget) {
1004
                        tabbableNodes = tabbableNodes.filter(function(index, element) {
1005
                            return stepTarget !== null
1006
                                && (stepTarget.has(element).length
1007
                                    || dialogContainer.has(element).length
1008
                                    || stepTarget.is(element)
1009
                                    || dialogContainer.is(element));
1010
                        });
1011
                    }
1012
 
1013
                    // Find index of focusing element.
1014
                    tabbableNodes.each(function(index, element) {
1015
                        if (activeElement.is(element)) {
1016
                            currentIndex = index;
1017
                            return false;
1018
                        }
1019
                        // Keep looping.
1020
                        return true;
1021
                    });
1022
 
1023
                    let nextIndex;
1024
                    let nextNode;
1025
                    let focusRelevant;
1026
                    if (currentIndex != void 0) {
1027
                        let direction = 1;
1028
                        if (e.shiftKey) {
1029
                            direction = -1;
1030
                        }
1031
                        nextIndex = currentIndex;
1032
                        do {
1033
                            nextIndex += direction;
1034
                            nextNode = $(tabbableNodes[nextIndex]);
1035
                        } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
1036
                        if (nextNode.length) {
1037
                            // A new f
1038
                            focusRelevant = nextNode.closest(stepTarget).length;
1039
                            focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
1040
                        } else {
1041
                            // Unable to find the target somehow.
1042
                            focusRelevant = false;
1043
                        }
1044
                    }
1045
 
1046
                    if (focusRelevant) {
1047
                        nextNode.focus();
1048
                    } else {
1049
                        if (e.shiftKey) {
1050
                            // Focus on the last tabbable node in the step.
1051
                            this.currentStepNode.find(tabbableSelector).last().focus();
1052
                        } else {
1053
                            if (this.currentStepConfig.isOrphan) {
1054
                                // Focus on the step - there is no target.
1055
                                this.currentStepNode.focus();
1056
                            } else {
1057
                                // Focus on the step target.
1058
                                stepTarget.focus();
1059
                            }
1060
                        }
1061
                    }
1062
                    e.preventDefault();
1063
                }).call(this);
1064
                break;
1065
        }
1066
    }
1067
 
1068
    /**
1069
     * Start the current tour.
1070
     *
1071
     * @method  startTour
1072
     * @param   {Number} startAt Which step number to start at. If not specified, starts at the last point.
1073
     * @chainable
1074
     * @return {Object} this.
1075
     * @fires tool_usertours/tourStart
1076
     * @fires tool_usertours/tourStarted
1077
     */
1078
    startTour(startAt) {
1079
        if (this.storage && typeof startAt === 'undefined') {
1080
            let storageStartValue = this.storage.getItem(this.storageKey);
1081
            if (storageStartValue) {
1082
                let storageStartAt = parseInt(storageStartValue, 10);
1083
                if (storageStartAt <= this.steps.length) {
1084
                    startAt = storageStartAt;
1085
                }
1086
            }
1087
        }
1088
 
1089
        if (typeof startAt === 'undefined') {
1090
            startAt = this.getCurrentStepNumber();
1091
        }
1092
 
1093
        const tourStartEvent = this.dispatchEvent(eventTypes.tourStart, {startAt}, true);
1094
        if (!tourStartEvent.defaultPrevented) {
1095
            this.gotoStep(startAt);
1096
            this.tourRunning = true;
1097
            this.dispatchEvent(eventTypes.tourStarted, {startAt});
1098
        }
1099
 
1100
        return this;
1101
    }
1102
 
1103
    /**
1104
     * Restart the tour from the beginning, resetting the completionlag.
1105
     *
1106
     * @method  restartTour
1107
     * @chainable
1108
     * @return {Object} this.
1109
     */
1110
    restartTour() {
1111
        return this.startTour(0);
1112
    }
1113
 
1114
    /**
1115
     * End the current tour.
1116
     *
1117
     * @method  endTour
1118
     * @chainable
1119
     * @return {Object} this.
1120
     * @fires tool_usertours/tourEnd
1121
     * @fires tool_usertours/tourEnded
1122
     */
1123
    endTour() {
1124
        const tourEndEvent = this.dispatchEvent(eventTypes.tourEnd, {}, true);
1125
        if (tourEndEvent.defaultPrevented) {
1126
            return this;
1127
        }
1128
 
1129
        if (this.currentStepConfig) {
1130
            let previousTarget = this.getStepTarget(this.currentStepConfig);
1131
            if (previousTarget) {
1132
                if (!previousTarget.attr('tabindex')) {
1133
                    previousTarget.attr('tabindex', '-1');
1134
                }
1135
                previousTarget.first().focus();
1136
            }
1137
        }
1138
 
1139
        this.hide(true);
1140
 
1141
        this.tourRunning = false;
1142
        this.dispatchEvent(eventTypes.tourEnded);
1143
 
1144
        return this;
1145
    }
1146
 
1147
    /**
1148
     * Hide any currently visible steps.
1149
     *
1150
     * @method hide
1151
     * @param {Bool} transition Animate the visibility change
1152
     * @chainable
1153
     * @return {Object} this.
1154
     * @fires tool_usertours/stepHide
1155
     * @fires tool_usertours/stepHidden
1156
     */
1157
    hide(transition) {
1158
        const stepHideEvent = this.dispatchEvent(eventTypes.stepHide, {}, true);
1159
        if (stepHideEvent.defaultPrevented) {
1160
            return this;
1161
        }
1162
 
1163
        if (this.currentStepNode && this.currentStepNode.length) {
1164
            this.currentStepNode.hide();
1165
            if (this.currentStepPopper) {
1166
                this.currentStepPopper.destroy();
1167
            }
1168
        }
1169
 
1170
        // Restore original target configuration.
1171
        if (this.currentStepConfig) {
1172
            let target = this.getStepTarget(this.currentStepConfig);
1173
            if (target) {
1174
                if (target.data('original-labelledby')) {
1175
                    target.attr('aria-labelledby', target.data('original-labelledby'));
1176
                }
1177
 
1178
                if (target.data('original-describedby')) {
1179
                    target.attr('aria-describedby', target.data('original-describedby'));
1180
                }
1181
 
1182
                if (target.data('original-tabindex')) {
1183
                    target.attr('tabindex', target.data('tabindex'));
1184
                } else {
1185
                    // If the target does not have the tabindex attribute at the beginning. We need to remove it.
1186
                    // We should wait a little here before removing the attribute to prevent the browser from adding it again.
1187
                    window.setTimeout(() => {
1188
                        target.removeAttr('tabindex');
1189
                    }, 400);
1190
                }
1191
            }
1192
 
1193
            // Clear the step configuration.
1194
            this.currentStepConfig = null;
1195
        }
1196
 
1197
        let fadeTime = 0;
1198
        if (transition) {
1199
            fadeTime = 400;
1200
        }
1201
 
1202
        // Remove the backdrop features.
1203
        $('[data-flexitour="step-background"]').remove();
1204
        $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
1205
        $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function() {
1206
            $(this).remove();
1207
        });
1208
 
1209
        // Remove aria-describedby and tabindex attributes.
1210
        if (this.currentStepNode && this.currentStepNode.length) {
1211
            let stepId = this.currentStepNode.attr('id');
1212
            if (stepId) {
1213
                let currentStepElement = '[aria-describedby="' + stepId + '-body"]';
1214
                $(currentStepElement).removeAttr('tabindex');
1215
                $(currentStepElement).removeAttr('aria-describedby');
1216
            }
1217
        }
1218
 
1219
        // Reset the listeners.
1220
        this.resetStepListeners();
1221
 
1222
        this.accessibilityHide();
1223
 
1224
        this.dispatchEvent(eventTypes.stepHidden);
1225
 
1226
        this.currentStepNode = null;
1227
        this.currentStepPopper = null;
1228
        return this;
1229
    }
1230
 
1231
    /**
1232
     * Show the current steps.
1233
     *
1234
     * @method show
1235
     * @chainable
1236
     * @return {Object} this.
1237
     */
1238
    show() {
1239
        // Show the current step.
1240
        let startAt = this.getCurrentStepNumber();
1241
 
1242
        return this.gotoStep(startAt);
1243
    }
1244
 
1245
    /**
1246
     * Return the current step node.
1247
     *
1248
     * @method  getStepContainer
1249
     * @return  {jQuery}
1250
     */
1251
    getStepContainer() {
1252
        return $(this.currentStepNode);
1253
    }
1254
 
1255
    /**
1256
     * Calculate scrollTop.
1257
     *
1258
     * @method  calculateScrollTop
1259
     * @param   {Object}    stepConfig      The step configuration of the step
1260
     * @return  {Number}
1261
     */
1262
    calculateScrollTop(stepConfig) {
1263
        let viewportHeight = $(window).height();
1264
        let targetNode = this.getStepTarget(stepConfig);
1265
 
1266
        let scrollParent = $(window);
1267
        if (targetNode.parents('[data-usertour="scroller"]').length) {
1268
            scrollParent = targetNode.parents('[data-usertour="scroller"]');
1269
        }
1270
        let scrollTop = scrollParent.scrollTop();
1271
 
1272
        if (stepConfig.placement === 'top') {
1273
            // If the placement is top, center scroll at the top of the target.
1274
            scrollTop = targetNode.offset().top - (viewportHeight / 2);
1275
        } else if (stepConfig.placement === 'bottom') {
1276
            // If the placement is bottom, center scroll at the bottom of the target.
1277
            scrollTop = targetNode.offset().top + targetNode.height() + scrollTop - (viewportHeight / 2);
1278
        } else if (targetNode.height() <= (viewportHeight * 0.8)) {
1279
            // If the placement is left/right, and the target fits in the viewport, centre screen on the target
1280
            scrollTop = targetNode.offset().top - ((viewportHeight - targetNode.height()) / 2);
1281
        } else {
1282
            // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
1283
            // and change step attachmentTarget to top+.
1284
            scrollTop = targetNode.offset().top - (viewportHeight * 0.2);
1285
        }
1286
 
1287
        // Never scroll over the top.
1288
        scrollTop = Math.max(0, scrollTop);
1289
 
1290
        // Never scroll beyond the bottom.
1291
        scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
1292
 
1293
        return Math.ceil(scrollTop);
1294
    }
1295
 
1296
    /**
1297
     * Calculate dialogue position for page middle.
1298
     *
1299
     * @param {jQuery} currentStepNode Current step node
1300
     * @method  calculateScrollTop
1301
     */
1302
    calculateStepPositionInPage(currentStepNode) {
1303
        let top = MINSPACING;
1304
        const viewportHeight = $(window).height();
1305
        const stepHeight = currentStepNode.height();
1306
        const viewportWidth = $(window).width();
1307
        const stepWidth = currentStepNode.width();
1308
        if (viewportHeight >= (stepHeight + (MINSPACING * 2))) {
1309
            top = Math.ceil((viewportHeight - stepHeight) / 2);
1310
        } else {
1311
            const headerHeight = currentStepNode.find('.modal-header').first().outerHeight() ?? 0;
1312
            const footerHeight = currentStepNode.find('.modal-footer').first().outerHeight() ?? 0;
1313
            const currentStepBody = currentStepNode.find('[data-placeholder="body"]').first();
1314
            const maxHeight = viewportHeight - (MINSPACING * 2) - headerHeight - footerHeight;
1315
            currentStepBody.css({
1316
                'max-height': maxHeight + 'px',
1317
                'overflow': 'auto',
1318
            });
1319
        }
1320
        currentStepNode.offset({
1321
            top: top,
1322
            left: Math.ceil((viewportWidth - stepWidth) / 2)
1323
        });
1324
    }
1325
 
1326
    /**
1327
     * Position the step on the page.
1328
     *
1329
     * @method  positionStep
1330
     * @param   {Object}    stepConfig      The step configuration of the step
1331
     * @chainable
1332
     * @return {Object} this.
1333
     */
1334
    positionStep(stepConfig) {
1335
        let content = this.currentStepNode;
1336
        let thisT = this;
1337
        if (!content || !content.length) {
1338
            // Unable to find the step node.
1339
            return this;
1340
        }
1341
 
1342
        stepConfig.placement = this.recalculatePlacement(stepConfig);
1343
        let flipBehavior;
1344
        switch (stepConfig.placement) {
1345
            case 'left':
1346
                flipBehavior = ['left', 'right', 'top', 'bottom'];
1347
                break;
1348
            case 'right':
1349
                flipBehavior = ['right', 'left', 'top', 'bottom'];
1350
                break;
1351
            case 'top':
1352
                flipBehavior = ['top', 'bottom', 'right', 'left'];
1353
                break;
1354
            case 'bottom':
1355
                flipBehavior = ['bottom', 'top', 'right', 'left'];
1356
                break;
1357
            default:
1358
                flipBehavior = 'flip';
1359
                break;
1360
        }
1361
 
1362
        let target = this.getStepTarget(stepConfig);
1363
        var config = {
1364
            placement: stepConfig.placement + '-start',
1365
            removeOnDestroy: true,
1366
            modifiers: {
1367
                flip: {
1368
                    behaviour: flipBehavior,
1369
                },
1370
                arrow: {
1371
                    element: '[data-role="arrow"]',
1372
                },
1373
            },
1374
            onCreate: function(data) {
1375
                recalculateArrowPosition(data);
1376
                recalculateStepPosition(data);
1377
            },
1378
            onUpdate: function(data) {
1379
                recalculateArrowPosition(data);
1380
                if (thisT.possitionNeedToBeRecalculated) {
1381
                    thisT.recalculatedNo++;
1382
                    thisT.possitionNeedToBeRecalculated = false;
1383
                    recalculateStepPosition(data);
1384
                }
1385
            },
1386
        };
1387
 
1388
        let recalculateArrowPosition = function(data) {
1389
            let placement = data.placement.split('-')[0];
1390
            const isVertical = ['left', 'right'].indexOf(placement) !== -1;
1391
            const arrowElement = data.instance.popper.querySelector('[data-role="arrow"]');
1392
            const stepElement = $(data.instance.popper.querySelector('[data-role="flexitour-step"]'));
1393
            if (isVertical) {
1394
                let arrowHeight = parseFloat(window.getComputedStyle(arrowElement).height);
1395
                let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).top);
1396
                let popperHeight = parseFloat(window.getComputedStyle(data.instance.popper).height);
1397
                let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).top);
1398
                let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1399
                let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1400
                let arrowPos = arrowOffset + (arrowHeight / 2);
1401
                let maxPos = popperHeight + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1402
                let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1403
                if (arrowPos >= maxPos || arrowPos <= minPos) {
1404
                    let newArrowPos = 0;
1405
                    if (arrowPos > (popperHeight / 2)) {
1406
                        newArrowPos = maxPos - arrowHeight;
1407
                    } else {
1408
                        newArrowPos = minPos + arrowHeight;
1409
                    }
1410
                    $(arrowElement).css('top', newArrowPos);
1411
                }
1412
            } else {
1413
                let arrowWidth = parseFloat(window.getComputedStyle(arrowElement).width);
1414
                let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).left);
1415
                let popperWidth = parseFloat(window.getComputedStyle(data.instance.popper).width);
1416
                let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).left);
1417
                let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1418
                let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1419
                let arrowPos = arrowOffset + (arrowWidth / 2);
1420
                let maxPos = popperWidth + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1421
                let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1422
                if (arrowPos >= maxPos || arrowPos <= minPos) {
1423
                    let newArrowPos = 0;
1424
                    if (arrowPos > (popperWidth / 2)) {
1425
                        newArrowPos = maxPos - arrowWidth;
1426
                    } else {
1427
                        newArrowPos = minPos + arrowWidth;
1428
                    }
1429
                    $(arrowElement).css('left', newArrowPos);
1430
                }
1431
            }
1432
        };
1433
 
1434
        const recalculateStepPosition = function(data) {
1435
            const placement = data.placement.split('-')[0];
1436
            const isVertical = ['left', 'right'].indexOf(placement) !== -1;
1437
            const popperElement = $(data.instance.popper);
1438
            const targetElement = $(data.instance.reference);
1439
            const arrowElement = popperElement.find('[data-role="arrow"]');
1440
            const stepElement = popperElement.find('[data-role="flexitour-step"]');
1441
            const viewportHeight = $(window).height();
1442
            const viewportWidth = $(window).width();
1443
            const arrowHeight = parseFloat(arrowElement.outerHeight(true));
1444
            const popperHeight = parseFloat(popperElement.outerHeight(true));
1445
            const targetHeight = parseFloat(targetElement.outerHeight(true));
1446
            const arrowWidth = parseFloat(arrowElement.outerWidth(true));
1447
            const popperWidth = parseFloat(popperElement.outerWidth(true));
1448
            const targetWidth = parseFloat(targetElement.outerWidth(true));
1449
            let maxHeight;
1450
 
1451
            if (thisT.recalculatedNo > 1) {
1452
                // The current screen is too small, and cannot fit with the original placement.
1453
                // We should set the placement to auto so the PopperJS can calculate the perfect placement.
1454
                thisT.currentStepPopper.options.placement = isVertical ? 'auto-left' : 'auto-bottom';
1455
            }
1456
            if (thisT.recalculatedNo > 2) {
1457
                // Return here to prevent recursive calling.
1458
                return;
1459
            }
1460
 
1461
            if (isVertical) {
1462
                // Find the best place to put the tour: Left of right.
1463
                const leftSpace = targetElement.offset().left > 0 ? targetElement.offset().left : 0;
1464
                const rightSpace = viewportWidth - leftSpace - targetWidth;
1465
                const remainingSpace = leftSpace >= rightSpace ? leftSpace : rightSpace;
1466
                maxHeight = viewportHeight - MINSPACING * 2;
1467
                if (remainingSpace < (popperWidth + arrowWidth)) {
1468
                    const maxWidth = remainingSpace - MINSPACING - arrowWidth;
1469
                    if (maxWidth > 0) {
1470
                        popperElement.css({
1471
                            'max-width': maxWidth + 'px',
1472
                        });
1473
                        // Not enough space, flag true to make Popper to recalculate the position.
1474
                        thisT.possitionNeedToBeRecalculated = true;
1475
                    }
1476
                } else if (maxHeight < popperHeight) {
1477
                    // Check if the Popper's height can fit the viewport height or not.
1478
                    // If not, set the correct max-height value for the Popper element.
1479
                    popperElement.css({
1480
                        'max-height': maxHeight + 'px',
1481
                    });
1482
                }
1483
            } else {
1484
                // Find the best place to put the tour: Top of bottom.
1485
                const topSpace = targetElement.offset().top > 0 ? targetElement.offset().top : 0;
1486
                const bottomSpace = viewportHeight - topSpace - targetHeight;
1487
                const remainingSpace = topSpace >= bottomSpace ? topSpace : bottomSpace;
1488
                maxHeight = remainingSpace - MINSPACING - arrowHeight;
1489
                if (remainingSpace < (popperHeight + arrowHeight)) {
1490
                    // Not enough space, flag true to make Popper to recalculate the position.
1491
                    thisT.possitionNeedToBeRecalculated = true;
1492
                }
1493
            }
1494
 
1495
            // Check if the Popper's height can fit the viewport height or not.
1496
            // If not, set the correct max-height value for the body.
1497
            const currentStepBody = stepElement.find('[data-placeholder="body"]').first();
1498
            const headerEle = stepElement.find('.modal-header').first();
1499
            const footerEle = stepElement.find('.modal-footer').first();
1500
            const headerHeight = headerEle.outerHeight(true) ?? 0;
1501
            const footerHeight = footerEle.outerHeight(true) ?? 0;
1502
            maxHeight = maxHeight - headerHeight - footerHeight;
1503
            if (maxHeight > 0) {
1504
                headerEle.removeClass('minimal');
1505
                footerEle.removeClass('minimal');
1506
                currentStepBody.css({
1507
                    'max-height': maxHeight + 'px',
1508
                    'overflow': 'auto',
1509
                });
1510
            } else {
1511
                headerEle.addClass('minimal');
1512
                footerEle.addClass('minimal');
1513
            }
1514
            // Call the Popper update method to update the position.
1515
            thisT.currentStepPopper.update();
1516
        };
1517
 
1518
        let background = $('[data-flexitour="step-background"]');
1519
        if (background.length) {
1520
            target = background;
1521
        }
1522
        this.currentStepPopper = new Popper(target, content[0], config);
1523
 
1524
        return this;
1525
    }
1526
 
1527
    /**
1528
     * For left/right placement, checks that there is room for the step at current window size.
1529
     *
1530
     * If there is not enough room, changes placement to 'top'.
1531
     *
1532
     * @method  recalculatePlacement
1533
     * @param   {Object}    stepConfig      The step configuration of the step
1534
     * @return  {String}                    The placement after recalculate
1535
     */
1536
    recalculatePlacement(stepConfig) {
1537
        const buffer = 10;
1538
        const arrowWidth = 16;
1539
        let target = this.getStepTarget(stepConfig);
1540
        let widthContent = this.currentStepNode.width() + arrowWidth;
1541
        let targetOffsetLeft = target.offset().left - buffer;
1542
        let targetOffsetRight = target.offset().left + target.width() + buffer;
1543
        let placement = stepConfig.placement;
1544
 
1545
        if (['left', 'right'].indexOf(placement) !== -1) {
1546
            if ((targetOffsetLeft < (widthContent + buffer)) &&
1547
                ((targetOffsetRight + widthContent + buffer) > document.documentElement.clientWidth)) {
1548
                placement = 'top';
1549
            }
1550
        }
1551
        return placement;
1552
    }
1553
 
1554
    /**
1555
     * Add the backdrop.
1556
     *
1557
     * @method  positionBackdrop
1558
     * @param   {Object}    stepConfig      The step configuration of the step
1559
     * @chainable
1560
     * @return {Object} this.
1561
     */
1562
    positionBackdrop(stepConfig) {
1563
        if (stepConfig.backdrop) {
1564
            this.currentStepConfig.hasBackdrop = true;
1565
            let backdrop = $('<div data-flexitour="backdrop"></div>');
1566
 
1567
            if (stepConfig.zIndex) {
1568
                if (stepConfig.attachPoint === 'append') {
1569
                    stepConfig.attachTo.append(backdrop);
1570
                } else {
1571
                    backdrop.insertAfter(stepConfig.attachTo);
1572
                }
1573
            } else {
1574
                $('body').append(backdrop);
1575
            }
1576
 
1577
            if (this.isStepActuallyVisible(stepConfig)) {
1578
                // The step has a visible target.
1579
                // Punch a hole through the backdrop.
1580
                let background = $('[data-flexitour="step-background"]');
1581
                if (!background.length) {
1582
                    background = $('<div data-flexitour="step-background"></div>');
1583
                }
1584
 
1585
                let targetNode = this.getStepTarget(stepConfig);
1586
 
1587
                let buffer = 10;
1588
 
1589
                let colorNode = targetNode;
1590
                if (buffer) {
1591
                    colorNode = $('body');
1592
                }
1593
 
1594
                let drawertop = 0;
1595
                if (targetNode.parents('[data-usertour="scroller"]').length) {
1596
                    const scrollerElement = targetNode.parents('[data-usertour="scroller"]');
1597
                    const navigationBuffer = scrollerElement.offset().top;
1598
                    if (scrollerElement.scrollTop() >= navigationBuffer) {
1599
                        drawertop = scrollerElement.scrollTop() - navigationBuffer;
1600
                        background.css({
1601
                            position: 'fixed'
1602
                        });
1603
                    }
1604
                }
1605
 
1606
                background.css({
1607
                    width: targetNode.outerWidth() + buffer + buffer,
1608
                    height: targetNode.outerHeight() + buffer + buffer,
1609
                    left: targetNode.offset().left - buffer,
1610
                    top: targetNode.offset().top + drawertop - buffer,
1611
                    backgroundColor: this.calculateInherittedBackgroundColor(colorNode),
1612
                });
1613
 
1614
                if (targetNode.offset().left < buffer) {
1615
                    background.css({
1616
                        width: targetNode.outerWidth() + targetNode.offset().left + buffer,
1617
                        left: targetNode.offset().left,
1618
                    });
1619
                }
1620
 
1621
                if ((targetNode.offset().top + drawertop) < buffer) {
1622
                    background.css({
1623
                        height: targetNode.outerHeight() + targetNode.offset().top + buffer,
1624
                        top: targetNode.offset().top,
1625
                    });
1626
                }
1627
 
1628
                let targetRadius = targetNode.css('borderRadius');
1629
                if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
1630
                    background.css('borderRadius', targetRadius);
1631
                }
1632
 
1633
                let targetPosition = this.calculatePosition(targetNode);
1634
                if (targetPosition === 'absolute') {
1635
                    background.css('position', 'fixed');
1636
                }
1637
 
1638
                let fader = background.clone();
1639
                fader.css({
1640
                    backgroundColor: backdrop.css('backgroundColor'),
1641
                    opacity: backdrop.css('opacity'),
1642
                });
1643
                fader.attr('data-flexitour', 'step-background-fader');
1644
 
1645
                if (!stepConfig.zIndex) {
1646
                    let targetClone = targetNode.clone();
1647
                    background.append(targetClone.first());
1648
                    $('body').append(fader);
1649
                    $('body').append(background);
1650
                } else {
1651
                    if (stepConfig.attachPoint === 'append') {
1652
                        stepConfig.attachTo.append(background);
1653
                    } else {
1654
                        fader.insertAfter(stepConfig.attachTo);
1655
                        background.insertAfter(stepConfig.attachTo);
1656
                    }
1657
                }
1658
 
1659
                // Add the backdrop data to the actual target.
1660
                // This is the part which actually does the work.
1661
                targetNode.attr('data-flexitour', 'step-backdrop');
1662
 
1663
                if (stepConfig.zIndex) {
1664
                    backdrop.css('zIndex', stepConfig.zIndex);
1665
                    background.css('zIndex', stepConfig.zIndex + 1);
1666
                    targetNode.css('zIndex', stepConfig.zIndex + 2);
1667
                }
1668
 
1669
                fader.fadeOut('2000', function() {
1670
                    $(this).remove();
1671
                });
1672
            }
1673
        }
1674
        return this;
1675
    }
1676
 
1677
    /**
1678
     * Calculate the inheritted z-index.
1679
     *
1680
     * @method  calculateZIndex
1681
     * @param   {jQuery}    elem                        The element to calculate z-index for
1682
     * @return  {Number}                                Calculated z-index
1683
     */
1684
    calculateZIndex(elem) {
1685
        elem = $(elem);
1686
        if (this.requireDefaultTourZindex(elem)) {
1687
            return 0;
1688
        }
1689
        while (elem.length && elem[0] !== document) {
1690
            // Ignore z-index if position is set to a value where z-index is ignored by the browser
1691
            // This makes behavior of this function consistent across browsers
1692
            // WebKit always returns auto if the element is positioned.
1693
            let position = elem.css("position");
1694
            if (position === "absolute" || position === "fixed") {
1695
                // IE returns 0 when zIndex is not specified
1696
                // other browsers return a string
1697
                // we ignore the case of nested elements with an explicit value of 0
1698
                // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
1699
                let value = parseInt(elem.css("zIndex"), 10);
1700
                if (!isNaN(value) && value !== 0) {
1701
                    return value;
1702
                }
1703
            }
1704
            elem = elem.parent();
1705
        }
1706
 
1707
        return 0;
1708
    }
1709
 
1710
    /**
1711
     * Check if the element require the default tour z-index.
1712
     *
1713
     * Some page elements have fixed z-index. However, their weight is not enough to cover
1714
     * other page elements like the top navbar or a sticky footer so they use the default
1715
     * tour z-index instead.
1716
     *
1717
     * @param {jQuery} elem the page element to highlight
1718
     * @return {Boolean} true if the element requires the default tour z-index instead of the calculated one
1719
     */
1720
    requireDefaultTourZindex(elem) {
1721
        if (elem.parents('[data-region="fixed-drawer"]').length !== 0) {
1722
            return true;
1723
        }
1724
        return false;
1725
    }
1726
 
1727
    /**
1728
     * Calculate the inheritted background colour.
1729
     *
1730
     * @method  calculateInherittedBackgroundColor
1731
     * @param   {jQuery}    elem                        The element to calculate colour for
1732
     * @return  {String}                                Calculated background colour
1733
     */
1734
    calculateInherittedBackgroundColor(elem) {
1735
        // Use a fake node to compare each element against.
1736
        let fakeNode = $('<div>').hide();
1737
        $('body').append(fakeNode);
1738
        let fakeElemColor = fakeNode.css('backgroundColor');
1739
        fakeNode.remove();
1740
 
1741
        elem = $(elem);
1742
        while (elem.length && elem[0] !== document) {
1743
            let color = elem.css('backgroundColor');
1744
            if (color !== fakeElemColor) {
1745
                return color;
1746
            }
1747
            elem = elem.parent();
1748
        }
1749
 
1750
        return null;
1751
    }
1752
 
1753
    /**
1754
     * Calculate the inheritted position.
1755
     *
1756
     * @method  calculatePosition
1757
     * @param   {jQuery}    elem                        The element to calculate position for
1758
     * @return  {String}                                Calculated position
1759
     */
1760
    calculatePosition(elem) {
1761
        elem = $(elem);
1762
        while (elem.length && elem[0] !== document) {
1763
            let position = elem.css('position');
1764
            if (position !== 'static') {
1765
                return position;
1766
            }
1767
            elem = elem.parent();
1768
        }
1769
 
1770
        return null;
1771
    }
1772
 
1773
    /**
1774
     * Perform accessibility changes for step shown.
1775
     *
1776
     * This will add aria-hidden="true" to all siblings and parent siblings.
1777
     *
1778
     * @method  accessibilityShow
1779
     */
1780
    accessibilityShow() {
1781
        let stateHolder = 'data-has-hidden';
1782
        let attrName = 'aria-hidden';
1783
        let hideFunction = function(child) {
1784
            let flexitourRole = child.data('flexitour');
1785
            if (flexitourRole) {
1786
                switch (flexitourRole) {
1787
                    case 'container':
1788
                    case 'target':
1789
                        return;
1790
                }
1791
            }
1792
 
1793
            let hidden = child.attr(attrName);
1794
            if (!hidden) {
1795
                child.attr(stateHolder, true);
1796
                Aria.hide(child);
1797
            }
1798
        };
1799
 
1800
        this.currentStepNode.siblings().each(function(index, node) {
1801
            hideFunction($(node));
1802
        });
1803
        this.currentStepNode.parentsUntil('body').siblings().each(function(index, node) {
1804
            hideFunction($(node));
1805
        });
1806
    }
1807
 
1808
    /**
1809
     * Perform accessibility changes for step hidden.
1810
     *
1811
     * This will remove any newly added aria-hidden="true".
1812
     *
1813
     * @method  accessibilityHide
1814
     */
1815
    accessibilityHide() {
1816
        let stateHolder = 'data-has-hidden';
1817
        let showFunction = function(child) {
1818
            let hidden = child.attr(stateHolder);
1819
            if (typeof hidden !== 'undefined') {
1820
                child.removeAttr(stateHolder);
1821
                Aria.unhide(child);
1822
            }
1823
        };
1824
 
1825
        $('[' + stateHolder + ']').each(function(index, node) {
1826
            showFunction($(node));
1827
        });
1828
    }
1829
};
1830
 
1831
export default Tour;