Proyectos de Subversion Moodle

Rev

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