Source: widget/tour/View.js

(function() {
    /**
     * @typedef {Help4.control2.container.Container.Params} Help4.widget.tour.View.Params
     * @property {Help4.widget.tour.Widget} widget - the owner widget
     * @property {string} projectId - the project ID
     * @property {Help4.widget.help.CatalogueKeys} catalogueKey
     * @property {Help4.widget.help.Project} project - the project data
     * @property {boolean} whatsnew - whatsnew mode enabled
     * @property {?string} [startAt] - a possible tour step ID
     * @property {?string} [startAfter] - a possible tour step ID
     * @property {?string} [history] - a possible history from last playback
     */

    /**
     * @typedef {Object} Help4.widget.tour.View.SerializedStatus
     * @property {string} projectId
     * @property {Help4.widget.help.CatalogueTypes} catalogueType
     * @property {Help4.widget.help.CatalogueKeys} catalogueKey
     * @property {string} [step]
     * @property {string} [history]
     */

    /**
     * @typedef {Object} Help4.widget.tour.View.HotspotStatus
     * @property {?boolean} checked
     * @property {string} className
     * @property {boolean} contentEditable
     * @property {?HTMLElement} element
     * @property {boolean} exists
     * @property {boolean} focussed
     * @property {*} inputType
     * @property {?string} label
     * @property {?string} nodeName
     * @property {?string} placeholder
     * @property {?Help4.control2.PositionXY} point
     * @property {?Help4.control2.AreaXYWH} rect
     * @property {boolean} scrolled
     * @property {?string} text
     * @property {?boolean} topmost
     * @property {*} value
     * @property {boolean} visible
     */

    /**
     * Tour playback for tour widget.
     * @augments Help4.control2.container.Container
     * @property {Help4.widget.tour.Widget} __widget
     * @property {string} __projectId
     * @property {Help4.widget.help.CatalogueKeys} __catalogueKey
     * @property {Help4.widget.help.Project} __project
     * @property {boolean} __whatsnew
     * @property {?string} __startAt
     * @property {?string} __startAfter
     * @property {?string} __history
     * @property {?number} _step
     * @property {Object} _status
     * @property {Help4.History} _history
     * @property {?Help4.widget.tour.HotspotController} _hotspotController
     * @property {?Help4.widget.tour.BubbleController} _bubbleController
     * @property {?Help4.widget.tour.Observer} _observer
     * @property {number} _lastAutoProgress
     * @property {Function} _domRefreshExecutor
     * @property {Help4.observer.WindowObserver} _windowObserver
     * @property {Help4.observer.EventBusObserver} _eventBusObserver
     */
    Help4.widget.tour.View = class extends Help4.control2.container.Container {
        /**
         * @override
         * @param {Help4.widget.tour.View.Params} params
         */
        constructor(params) {
            Help4.widget.companionCore.Core.addStandardViewParameters(params.widget, params, 'full');

            const {WindowObserver, EventBusObserver} = Help4.observer;
            const onWindowObserver = event => {
                const key = Help4.service.HotkeyService.getKey(event);
                _onKeyEvent.call(this, key);
            }

            const T = Help4.jscore.ControlBase.TYPES;
            super(params, {
                params: {
                    widget:       {type: T.instance, mandatory: true, private: true, readonly: true},
                    projectId:    {type: T.string, mandatory: true, private: true, readonly: true},
                    catalogueKey: {type: T.string, mandatory: true, private: true, readonly: true},
                    project:      {type: T.object, mandatory: true, private: true, readonly: true},
                    whatsnew:     {type: T.boolean, mandatory: true, private: true, readonly: true},
                    startAt:      {type: T.string_null, private: true, readonly: true},
                    startAfter:   {type: T.string_null, private: true, readonly: true},
                    history:      {type: T.string, private: true, readonly: true}
                },
                statics: {
                    _step:               {init: null, destroy: false},
                    _status:             {init: {}, destroy: false},  /** see {@link Help4.widget.tour.View.HotspotStatus} */
                    _history:            {init: new Help4.History({})},
                    _hotspotController:  {},
                    _bubbleController:   {},
                    _observer:           {},
                    _lastAutoProgress:   {init: -1, destroy: false},
                    _domRefreshExecutor: {init: () => this.align(), destroy: false},
                    _windowObserver:     {init: new WindowObserver(onWindowObserver)},
                    _eventBusObserver:   {init: new EventBusObserver(({hotkey}) => _onKeyEvent.call(this, hotkey))},
                },
                config: {
                    css: 'widget-tour-view'
                }
            });
        }

        /** @returns {Help4.widget.tour.View.SerializedStatus} */
        serialize() {
            const {
                /** @type {string} */ __projectId: projectId,
                /** @type {string} */ __catalogueKey: catalogueKey,
                /** @type {boolean} */ __whatsnew: whatsnew,
                /** @type {Help4.History} */ _history,
                /** @type {Help4.widget.help.Project} */ __project: {
                    /** @type {Help4.widget.help.CatalogueTypes} */ _catalogueType: catalogueType
                }
            } = this;

            return {
                projectId,
                catalogueKey,
                catalogueType,
                whatsnew,
                step: this.getCurrentTile()?.id,
                history: _history.serialize()
            }
        }

        /** @override */
        clean() {
            this.stopElementObservation();
            return super.clean();
        }

        /** @returns {?string[]} */
        getAutoProgressKeys() {
            return this.getCurrentTile()?.autoProgress;
        }

        /** @override */
        _onBeforeDestroy() {
            super._onBeforeDestroy();

            const context = this.getContext();
            const {/** @type {Help4.service.CrossOriginMessageService} */ crossOriginService} = context.service;

            crossOriginService
            ?.sendCommand({type: Help4.engine.crossorigin.AGENT_TYPE.recording, command: 'setAutoProgressKeys', params: []})
            .catch(Help4.noop);

            this._observer.destroy();

            const {_domRefreshExecutor} = this;
            Help4.widget.companionCore.Core.disconnectDomRefresh(_domRefreshExecutor, context);

            const {prevTourstep, nextTourstep} = Help4.HOTKEY;
            context.service.hotkey.disableHotkey(prevTourstep, nextTourstep);
        }

        /**
         * @override
         * @param {Help4.widget.tour.View.Params} params - same params as provided to the constructor
         */
        _onAfterInit(params) {
            super._onAfterInit(params);

            /** @type {Help4.control2.AreaXYWH} */
            this.virtualArea = {x: 0, y: 0, w: window.innerWidth, h: window.innerHeight};

            const {tour, companionCore: {Core}} = Help4.widget;
            const {__history} = this;

            /** @type {Help4.widget.tour.HotspotController} */
            this._hotspotController = new tour.HotspotController(this);
            /** @type {Help4.widget.tour.BubbleController} */
            this._bubbleController = new tour.BubbleController(this);
            /** @type {Help4.widget.tour.Observer} */
            this._observer = new tour.Observer(this);

            __history && this._history.deserialize(__history);

            const context = this.getContext();
            Core.observeDomRefresh(this._domRefreshExecutor, context, this);

            this._windowObserver.observeAll({eventObserver: {type: ['keyup']}});

            const {prevTourstep, nextTourstep} = Help4.HOTKEY;
            context.service.hotkey.enableHotkey(prevTourstep, nextTourstep);
            this._eventBusObserver.observe(context.eventBus, {type: Help4.EventBus.TYPES.hotkey});
        }

        /**
         * @override
         * @param {HTMLElement} dom
         */
        _onDomAvailable(dom) {
            /** {@link Help4.service.container.BubbleService} */
            Help4.Element.setAttribute(dom, {  // XRAY-2374
                ariaLive: 'assertive',
                ariaAtomic: 'true',
                ariaRelevant: 'additions'
            });
        }

        /**
         * @override
         * @param {HTMLElement} dom
         * @returns {Promise<void>}
         */
        async _onDomCreated(dom) {
            if (!this.getSteps()) {
                // no tour steps available
                this.abort('label.tourfailure');
                return;
            }

            const {_history} = this;
            const first = _getFirstTileOnScreen.call(this);

            if (first < 0) {
                // no step on this screen - tour cannot play
                if (_history.count() > 0) {
                    // XRAY-1984: multi-page restart; show the last item
                    /** @type {string} */ const last = _history.last();  // last item before restart
                    /** @type {number} */ const index = this.getTileIndex(last);
                    if (index >= 0) await _showStep.call(this, index);
                    return;
                }

                this.abort('label.tournostep');
                return;
            }

            // tour is not empty and has at least one step on this screen

            const {
                /** @type {?string|number} */ __startAt,
                /** @type {?string|number} */  __startAfter
            } = this;

            if (__startAt) {  // start at a certain position
                /** @type {number} */ const step = this.getTileIndex(__startAt);
                if (await this.showStepAt(step)) return;
            }
            if (__startAfter) {  // start after a certain position
                /** @type {number} */ const step = this.getTileIndex(__startAfter);
                if (await this.showStepAt(step + 1)) return;  // try to start after __startAfter

                // try to start at the __startAfter position instead
                if (_history.last() === __startAfter) _history.pop();  // remove __startAfter from history to avoid double entries
                if (await this.showStepAt(step)) return;  // try to start at __startAfter

                // startAfter scenario failed; clean history
                _history.clean();
            }

            // start at the first tile on this screen
            await _showStep.call(this, first);
        }

        /**
         * forward {@link Help4.engine.ur.UrHarmonization} tour events to {@link Help4.widget.tour.Observer#onElementEvent}
         * similar to {@link Help4.widget.tour.View#_onKeyEvent}
         * @param {Help4.widget.tour.Observer.ElementEvent} event
         */
        onUrTourEvent(event) {
            this._observer.onElementEvent(event);
        }

        /**
         * @override
         * @returns {Help4.widget.tour.View}
         */
        focus() {
            const bubble = this.get({byMetadata: {type: 'bubble'}});
            bubble?.focusButton();

            return this;
        }

        /** @returns {Promise<boolean>} */
        async focusApp() {
            const {_step, _hotspotController} = this;
            const tile = this.getTile(_step);

            return _hotspotController.focusElement(tile);
        }

        /**
         * @param {number} step
         * @returns {Promise<boolean>}
         */
        async showStepAt(step) {
            if (step >= 0 && step < this.getSteps()) {
                /** @type {Help4.widget.help.ProjectTile} */ const tile = this.getTile(step);
                if (this.isTileOnScreen(tile)) {
                    await _showStep.call(this, step);
                    return true;
                }
            }
            return false;
        }

        /** @returns {Help4.widget.tour.Widget.Context} */
        getContext() {
           return this.__widget.getContext();
        }

        /** @returns {Help4.widget.help.Project} */
        getProject() {
            return this.__project;
        }

        /**
         * @param {string} tileId
         * @returns {number}
         */
        getTileIndex(tileId) {
            const {/** @type {Help4.widget.help.ProjectTile[]} */ tiles} = this.__project;
            return tiles.findIndex(({id}) => id === tileId);
        }

        /**
         * @param {number|string} step
         * @returns {?Help4.widget.help.ProjectTile}
         */
        getTile(step) {
            const {/** @type {Help4.widget.help.ProjectTile[]} */ tiles} = this.__project;
            return typeof step === 'number'
                ? tiles[step]
                : tiles.find(({id}) => id === step);
        }

        /** @returns {?Help4.widget.help.ProjectTile} */
        getCurrentTile() {
            return this.getTile(this._step);
        }

        /**
         * see {@link _updateHotspotStatus}
         * @param {number|string} step
         * @returns {?Help4.widget.tour.View.HotspotStatus}
         */
        getStatus(step) {
            /** @type {?Help4.widget.help.ProjectTile} */
            const tile = this.getTile(step);
            return this._status[tile?.id] || null;
        }

        /**
         * see {@link _updateHotspotStatus}
         * @returns {?Help4.widget.tour.View.HotspotStatus}
         */
        getCurrentStatus() {
            return this.getStatus(this._step);
        }

        /**
         * @param {Help4.widget.help.ProjectTile} tile
         * @param {?string} [screenId]
         * @returns {boolean}
         */
        isTileOnScreen({pageUrl}, screenId) {
            const {WHATSNEW_SCREEN_ID} = Help4.widget.help.CatalogueBackend;
            const key = this.__whatsnew ? WHATSNEW_SCREEN_ID : '';
            screenId ||= this.getContext().configuration.core.screenId;
            return pageUrl === `${screenId}${key}`;
        }

        /**
         * check if the next step will be on the same screen
         * @return {boolean}
         */
        isNextStepSameScreen() {
            const {pageUrl: next} = this.getTile(this._step + 1) || {};
            return !!next && next === this.getCurrentTile().pageUrl;
        }

        /**
         * called to deal with navigation events
         * @returns {Promise<void>}
         */
        async afterNavigate() {
            // XRAY-133: several auto progress options are available
            // might be that auto progress has taken place already; do not execute twice
            if (this._lastAutoProgress === this._step) return;

            // if possible: do auto continue after navigation
            if (!await this.nextStep()) {
                // if not: visually invalidate the existing bubble
                this._bubbleController.invalidate();
            }
        }

        /**
         * @param {string} reason
         * @returns {Help4.widget.tour.View}
         */
        updateHistory(reason) {
            if (reason === 'prev') {  // remove last history entry
                this._history.pop();
            } else {  // add history entry
                const {id} = this.getCurrentTile();
                this._history.push(id);
            }
            return this;
        }

        /** @returns {Help4.widget.tour.View} */
        trackStep() {
            const {id: step, title} = this.getCurrentTile();
            const {/** @type {Help4.controller.Controller} */ controller} = this.getContext();
            const tracking = /** @type {Help4.tracking.Tracking} */ controller.getService('tracking');
            tracking?.trackProject({
                verb: 'step',
                type: 'tour',
                step,
                title
            });

            this._fireEvent({type: 'updateTour'});
            return this;
        }

        /** @returns {number|null} */
        getStep() {
            return this._step;
        }

        /** @returns {number} */
        getSteps() {
            const {/** @type {Help4.widget.help.ProjectTile[]} */ tiles} = this.__project;
            return tiles.length;
        }

        /**
         * {@link Help4.controller.Tour.prototype.vetoNavigation}
         * @param {string} screenId
         * @returns {boolean}
         */
        vetoNavigation(screenId) {
            if (this.isNextStepAvailable(undefined, screenId)) return false;

            const bubble = this._bubbleController.get();
            const visible = bubble?.visible || false;
            if (bubble) bubble.visible = false;

            const onButton = (eventId, dialog, buttonId) => {
                if (bubble) bubble.visible = visible;
                if (buttonId === 'yes') this.stop();
            }

            const {Localization} = Help4;
            const {/** @type {Help4.controller.Controller} */ controller} = this.getContext();

            // XXX: message service needs to run in DOM2 for CMP4
            /** @type {Help4.service.container.MessageService} */
            const messageService = controller.getService('message');
            messageService.add({
                id: 'tourVetoNavigation',
                caption: Localization.getText('header.tourleave'),
                content: Localization.getText('label.tourleave'),
                onbutton: onButton
            });
            return true;
        }

        /** @returns {boolean} */
        isPrevStepAvailable() {
            // last history entry is in front;
            // check for go back looks at previous to last entry
            const foreLast = this._history.forelast();
            if (foreLast) {
                /** @type {?Help4.widget.help.ProjectTile} */
                const tile = this.getTile(foreLast);  // prev tile
                if (tile) return this.isTileOnScreen(tile);
            }
            return false;
        }

        /**
         * @param {number} [step]
         * @param {string} [screenId]
         * @returns {boolean}
         */
        isNextStepAvailable(step, screenId) {
            step ??= this._step;

            if (++step < this.getSteps()) {
                /** @type {?Help4.widget.help.ProjectTile} */
                const tile = this.getTile(step);  // next tile
                if (tile) return this.isTileOnScreen(tile, screenId);
            }
            return false;
        }

        /** @returns {Help4.control2.hotspot.Connected|null} */
        getHotspotControl() {
            return this._hotspotController.get();
        }

        /**
         * aborts tour playback in case of failure
         * @param {string} message
         * @param {string} [appearance = 'warning']
         */
        abort(message, appearance = 'warning') {
            // allow normal execution flow to continue before firing stop
            const {
                /** @type {Help4.service.InfobarService} */ infobarService
            } = this.getContext().service;
            const {
                Localization,
                control2: {InfoBar: {TYPES}}
            } = Help4;

            infobarService.add({
                type: TYPES[appearance],
                content: Localization.getText(message)
            });

            // allow normal execution flow to continue before firing stop
            setTimeout(() => this.stop(), 1);
        }

        /** @returns {Help4.widget.tour.View} */
        stop() {
            this._fireEvent({type: 'stopTour'});
            return this;
        }

        /**
         * @param {boolean} [autoProgress = false]
         * @returns {Promise<boolean>}
         */
        async nextStep(autoProgress = false) {
            if (this.isNextStepAvailable()) {
                this._lastAutoProgress = autoProgress ? this._step : -1;
                await _showStep.call(this, this._step + 1);
                return true;
            } else {
                this._lastAutoProgress = -1;
            }
            return false;
        }

        /** @returns {Promise<boolean>} */
        async prevStep() {
            if (this.isPrevStepAvailable()) {
                const step = this.getTileIndex(this._history.forelast());
                await _showStep.call(this, step, 'prev');
                return true;
            }
            return false;
        }

        /** align hotspot and bubble */
        async align() {
            this.virtualArea = {x: 0, y: 0, w: window.innerWidth, h: window.innerHeight};

            // calculate hotspot status beginning from current
            await _getCurrentStatus.call(this);
            await this._hotspotController?.update();
            await this._bubbleController?.update();
        }

        /**
         * @param {Help4.data.TileData} tileData
         * @returns {Help4.widget.tour.View}
         */
        startElementObservation(tileData) {
            this._observer.observe(tileData);
            return this;
        }

        /** @returns {Help4.widget.tour.View} */
        stopElementObservation() {
            this._observer.disconnect();
            return this;
        }
    }

    /**
     * @memberof Help4.widget.tour.View#
     * @private
     * @param {number} step
     * @param {string} [reason = 'next']
     * @returns {Promise<void>}
     */
    async function _showStep(step, reason = 'next') {
        this._step = step;

        // calculate hotspot status beginning from current
        await _getAllNextStatus.call(this);
        if (this.isDestroyed()) return;

        /** @type {Help4.widget.help.ProjectTile} */
        const tile = this.getCurrentTile();
        /** @type {Help4.widget.tour.View.HotspotStatus} */
        const {visible} = this.getCurrentStatus();

        if (reason !== 'prev' && tile.autoSkipStep && !visible) {
            await _autoSkipSteps.call(this);
        } else {
            const {AGENT_TYPE} = Help4.engine.crossorigin;
            const {
                engine: {/** @type {Help4.engine.crossorigin.CoreEngine} */ crossOriginEngine},
                service: {/** @type {Help4.service.CrossOriginMessageService} */ crossOriginService}
            } = this.getContext();

            crossOriginEngine
            ?.sendCommand({type: AGENT_TYPE.recording, command: 'setTileData', params: tile})
            .catch(Help4.noop);

            crossOriginService
            ?.sendCommand({type: AGENT_TYPE.recording, command: 'setAutoProgressKeys', params: tile.autoProgress})
            .catch(Help4.noop);

            this.updateHistory(reason);  // update history
            this.trackStep();  // track step information

            this.clean();  // XRAY-4629: clean possible open bubbles
            await this._hotspotController.create();  // create hotspot for current step
            await this._bubbleController.create(reason);  // create bubble for current step
        }
    }

    /**
     * calculates the status of the current hotspot
     * @memberof Help4.widget.tour.View#
     * @private
     * @returns {Promise<void>}
     */
    async function _getCurrentStatus() {
        const {/** @type {Object} */_status} = this;
        /** @type {Help4.widget.help.ProjectTile} */ const tile = this.getCurrentTile();
        const {id} = tile;

        if (this.isTileOnScreen(tile)) {
            const {hotspotAnchor, hotspotCentered} = tile;

            if (hotspotCentered || !hotspotAnchor) {
                // centered hotspot always visible
                _status[id] = {visible: true};
            } else {
                // calculate status for hotspots on this screen
                await _updateHotspotStatus.call(this, [tile]);
            }
        } else {
            // not on screen or invalid hotspot information
            _status[id] = {visible: false};
        }
    }

    /**
     * calculates the status of all hotspots beginning from the current step
     * @memberof Help4.widget.tour.View#
     * @private
     * @returns {Promise<void>}
     */
    async function _getAllNextStatus() {
        const {
            /** @type {Help4.widget.help.Project} */ __project,
            /** @type {Object} */ _status,
            /** @type {number} */ _step
        } = this;

        /** @type {Array<Help4.widget.help.ProjectTile>} */ const needStatusUpdate = [];
        const {/** @type {Help4.widget.help.ProjectTile[]} */ tiles} = __project;
        const steps = tiles.length;

        // determine the hotspot status for each tour step from the current one onwards
        for (let i = _step; i < steps; i++) {
            /** @type {Help4.widget.help.ProjectTile} */ const tile = tiles[i];
            const {id} = tile;

            if (this.isTileOnScreen(tile)) {
                const {hotspotAnchor, hotspotCentered} = tile;

                if (hotspotCentered || !hotspotAnchor) {
                    // centered hotspot always visible
                    _status[id] = {visible: true};
                } else {
                    // hotspot status to be determined
                    needStatusUpdate.push(tile);
                }
            } else {
                // not on screen or invalid hotspot information
                _status[id] = {visible: false};
            }
        }

        if (needStatusUpdate.length) {
            // calculate status for hotspots on this screen
            await _updateHotspotStatus.call(this, needStatusUpdate);
        }
    }

    /**
     * @memberof Help4.widget.tour.View#
     * @private
     * @param {Array<Help4.widget.help.ProjectTile>} list
     * @returns {Promise<void>}
     */
    async function _updateHotspotStatus(list) {
        const {
            /** @type {Help4.service.recording.PlaybackService} */ playbackService,
            /** @type {Help4.service.recording.PlaybackCacheService} */ playbackCacheService
        } = this.getContext().service;

        /** @type {Help4.data.TileData[]} */
        const tileDataList = list.map(tile => Help4.widget.companionCore.Core.tileToTileData(tile));
        /** @type {Help4.data.HotspotData[]} */
        const hotspotDataList = tileDataList.map(tileData => tileData.hotspot);

        playbackCacheService?.clean();
        await playbackService?.update(hotspotDataList);  // this will update all status entries within hotspotDataList

        if (!this.isDestroyed()) {
            // map the information into this._status
            const {/** @type {Object} */ _status} = this;
            tileDataList.forEach(({id, hotspot: {status}}) => _status[id] = status.toObject());
        }
    }

    /**
     * skip current step
     * @memberof Help4.widget.tour.View#
     * @private
     * @returns {Promise<void>|undefined}
     */
    function _autoSkipSteps() {
        const steps = this.getSteps();
        let step = this._step;

        do {
            if (this.isNextStepAvailable(step++)) {
                // next step is on current screen

                const {autoSkipStep} = /** @type {Help4.widget.help.ProjectTile} */ this.getTile(step);
                const {visible} = /** @type {Help4.widget.tour.View.HotspotStatus} */ this.getStatus(step);

                if (visible || !autoSkipStep) {
                    // next step is visible or cannot be skipped; take that step
                    return _showStep.call(this, step);
                }
            } else {
                // next step is not on current screen; unable to progress further
                break;
            }
        } while (step + 1 < steps);

        // reached end of tour
        this.abort('label.tournostep');
    }

    /**
     * @memberof Help4.widget.tour.View#
     * @private
     * @returns {number}
     */
    function _getFirstTileOnScreen() {
        const {/** @type {Help4.widget.help.ProjectTile[]} */ tiles} = this.__project;
        for (const [index, tile] of Help4.arrayEntries(tiles)) {
            if (this.isTileOnScreen(tile)) return index;
        }
        return -1;
    }

    /**
     * @memberof Help4.widget.tour.View#
     * @private
     * @param {string} hotkey
     */
    function _onKeyEvent(hotkey) {
        const {HOTKEY} = Help4;
        switch (hotkey) {
            // window observer
            case 'tab':
            case 'enter':
                this._observer.onElementEvent({event: {type: hotkey}});
                break;

            // eventBus observer
            case HOTKEY.escape:
                this.stop();
                break;
            case HOTKEY.prevTourstep:
                this.prevStep();
                break;
            case HOTKEY.nextTourstep:
                this.nextStep();
                break;
        }
    }
})();