Proyectos de Subversion Moodle

Rev

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