Source: widget/companionCore/Core.js

(function() {
    /**
     * @namespace companionCore
     * @memberof Help4.widget
     */
    Help4.widget.companionCore = {};

    /**
     * @namespace data
     * @memberof Help4.widget.companionCore
     */
    Help4.widget.companionCore.data = {};

    /** @type {?string} */ let DOM_REFRESH_ID = null;
    /** @type {object} */  const EXECUTOR_MAP = [];

    /** core functionality */
    Help4.widget.companionCore.Core = class {
        /** @param {?Help4.typedef.XmlHttpResponse} [xhr = null] */
        static broadcastXhrStatus(xhr = null) {
            if (xhr) {
                const {status, responseText: text, hasTimeout = false} = xhr;
                const eventBus = Help4.getController()?.getService('eventBus');
                const {xhrStatus: type} = Help4.EventBus.TYPES;
                eventBus?.fire({
                    type,
                    data: {status, text, hasTimeout}
                });
            }
        }

        /**
         * @param {string} hotspotAnchor
         * @param {boolean} [useDescendents = false]
         * @returns {Promise<?Object>}
         */
        static async getElementHotspotStatus(hotspotAnchor, useDescendents = false) {
            const controller = Help4.getController();
            const {playback, playbackCache} = controller.getService('playback', 'playbackCache');
            const domRefreshEngine = controller.getEngine('domRefresh');

            const hotspotData = this.projectTileToHotspotData({hotspotAnchor, useDescendents});

            // make sure to receive non-cached results
            // this will update all status entries within hotspotDataList
            domRefreshEngine.sleep(true);  // prevent additional rescans due to our DOM manipulations during scan
            playbackCache?.clean();
            await playback?.update([hotspotData]);
            domRefreshEngine.sleep(false);

            return hotspotData.status?.toObject?.() || {visible: false};
        }

        /**
         * simulate hotspot data without creating the (slow) class instance
         * @param {Help4.widget.help.ProjectTile} projectTile
         * @param {Object} [hotspotStatus = {}]
         * @returns {Object}
         */
        static projectTileToHotspotData(projectTile, hotspotStatus = {}) {
            const {
                selector: {Selector},
                DEFAULTS
            } = Help4;

            /**
             * ATTENTION: ALWAYS KEEP IN SYNC!
             * {@link Help4.data.HotspotData}
             * {@link Help4.DEFAULTS}
             */
            const {
                id: tileId = null,
                hotspotId,
                title = '',
                hotspotAnchor = DEFAULTS.hotspotAnchor.d,
                hotspotOffset = DEFAULTS.hotspotOffset.d,
                hotspotSize = DEFAULTS.hotspotSize.d,
                hotspotCentered = DEFAULTS.hotspotCentered.d,
                hotspotStyle = DEFAULTS.hotspotStyle.d,
                hotspotIconType = DEFAULTS.hotspotIconType.d,
                hotspotIconPos = DEFAULTS.hotspotIconPos.d,
                hotspotTrianglePos = DEFAULTS.hotspotTrianglePos.d,
                hotspotRectDelta = {...DEFAULTS.hotspotRectDelta.d},
                hotspotManualOffset = {...DEFAULTS.hotspotManualOffset.d},
                hotspotSpotlight = DEFAULTS.hotspotSpotlight,
                hotspotSpotlightOffset = DEFAULTS.hotspotSpotlightOffset.d,
                hotspotSpotlightBlur = DEFAULTS.hotspotSpotlightBlur.d,
                hotspotSpotlightOpacity = DEFAULTS.hotspotSpotlightOpacity.d,
                hotspotAnimationType = DEFAULTS.hotspotAnimationType.d,
                callout = DEFAULTS.callout.d,
                instantHelp = DEFAULTS.instantHelp.d,
                alignWithText = DEFAULTS.alignWithText.d,

                // special data from ConditionService
                useDescendents = false,

                // UR Harmonization Engine
                _isUR
            } = projectTile;

            const selector = hotspotAnchor ? Selector.base64ToUtf8(hotspotAnchor) : null;
            const useOffset = hotspotStyle === 'CIRCLE' && hotspotOffset ||
                hotspotStyle === 'ICON' && hotspotIconPos === 'R';

            const data = {
                // from model
                tileId,
                hotspotId,
                title,
                selector,
                hotspotAnchor,
                hotspotOffset,
                hotspotSize,
                hotspotCentered,
                hotspotStyle,
                hotspotIconType,
                hotspotIconPos,
                hotspotTrianglePos,
                hotspotRectDelta,
                hotspotManualOffset,
                hotspotSpotlight,
                hotspotSpotlightOffset,
                hotspotSpotlightBlur,
                hotspotSpotlightOpacity,
                hotspotAnimationType,
                callout,
                instantHelp,
                alignWithText,

                // non-model data
                number: null,
                crossapp: null,
                useOffset,
                isPreview: false,
                useDescendents,
                fixedVisibility: null,
                controlStyle: hotspotStyle,
                _source: _isUR ? 'urEngine' : null,

                // hotspot status
                status: hotspotStatus,
            }

            data.isDestroyed = () => false;
            data.toObject = () => data;

            return data;
        }

        /**
         * @param {function(): void} executor
         * @param {Help4.widget.Widget.Context} context
         * @param {Object} [scope]
         */
        static observeDomRefresh(executor, context, scope) {
            const {
                engine: {/** @type {Help4.engine.DomRefreshEngine} */ domRefreshEngine},
                /** @type {Help4.controller.Controller} */ controller
            } = context;

            /** @returns {boolean} */
            const isEditMode = () => {
                /** @type {Help4.typedef.SystemConfiguration} */ const config = controller.getConfiguration();
                return config.core.isEditMode;
            }

            const _executor = () => {
                // do not execute anything in case CMP3 edit mode is active
                !isEditMode() && !scope?.isDestroyed?.() && executor();
            };

            EXECUTOR_MAP.push([executor, _executor]);
            domRefreshEngine.addExecutor(_executor);

            if (!DOM_REFRESH_ID) {
                DOM_REFRESH_ID = Help4.Queue.addJob(() => {
                    // XRAY-5976: controller might be destroyed in the meantime e.g. via API.setLanguage
                    // reset job and count
                    if (controller.isDestroyed()) {
                        Help4.Queue.removeJob(DOM_REFRESH_ID);
                        DOM_REFRESH_ID = null;
                        EXECUTOR_MAP.length = 0;
                        return;
                    }

                    // XRAY-5396, XRAY-5397
                    // in case of edit mode, CMP3 is in charge; do not disturb
                    !isEditMode() && domRefreshEngine.isStarted() && domRefreshEngine.execute();
                });
            }
        }

        /**
         * @param {Function} executor
         * @param {Help4.widget.Widget.Context} context
         */
        static disconnectDomRefresh(executor, context) {
            if (!EXECUTOR_MAP.length) return;  // no executor registered

            const {/** @type {Help4.engine.DomRefreshEngine} */ domRefreshEngine} = context.engine;

            for (let i = 0; i < EXECUTOR_MAP.length; i++) {
                const [e, _e] = EXECUTOR_MAP[i];
                if (e === executor) {
                    domRefreshEngine?.removeExecutor(_e);
                    EXECUTOR_MAP.splice(i, 1);
                    break;
                }
            }

            if (!EXECUTOR_MAP.length && DOM_REFRESH_ID) {
                Help4.Queue.removeJob(DOM_REFRESH_ID);
                DOM_REFRESH_ID = null;
            }
        }

        /**
         * @param {Help4.widget.Widget} widget
         * @param {Object} params
         * @param {?('full'|'contentDiv')} [domMode = null]
         * @returns {Object}
         */
        static addStandardViewParameters(widget, params, domMode = null) {
            const {
                /** @type {Help4.controller.Controller} */ controller,
                /** @type {Help4.typedef.SystemConfiguration} */ configuration,
                /** @type {?Help4.control2.bubble.Panel} */ panel
            } = /** @type {Help4.widget.Widget.Context} */ widget.getContext();

            // set standard parameters from config
            const {rtl, mobile, language} = configuration.core;
            params.rtl = rtl;
            params.mobile = mobile;
            params.language = language._;
            params.contentLanguage = language._;

            switch (domMode) {
                case 'full':
                    // for fullscreen views
                    params.dom = controller.getDom2('dom');
                    break;
                case 'contentDiv':
                    // for views within content div of panel
                    const contentInstance = /** @type {?Help4.control2.bubble.content.Panel} */ panel?.getContentInstance();
                    if (contentInstance) params.dom = /** @type {HTMLElement} */ contentInstance.useContentDiv();
                    break;
            }

            return params;
        }

        /**
         * some views require the playback service to evaluate e.g. conditions; wait until the service is ready
         * @param {Help4.widget.Widget} widget
         * @param {number} [resumeTime = 100]
         * @returns {Promise<void>}
         */
        static async waitControllerPlaybackServiceReady(widget, resumeTime = 100) {
            return new Help4.Promise(resolve => {
                let timeout = null;

                const afterWait = () => {
                    timeout && clearTimeout(timeout);
                    observer?.destroy();
                    resolve();
                }

                // observe readiness of playbackService for condition element evaluation

                const {
                    observer: {EventBusObserver},
                    EventBus: {TYPES: {controllerPlaybackServiceReady: type}}
                } = Help4;

                const {/** @type {Help4.EventBus} */ eventBus} = widget.getContext();
                const observer = new EventBusObserver(afterWait)
                .observe(eventBus, {type});

                // as an alternative (as event might have been gone already) just wait some time
                timeout = setTimeout(afterWait, resumeTime);
            });
        }

        /**
         * update a tile list view
         * @param {Object} params
         * @param {Function} params.isDestroyed
         * @param {boolean} [params.isRefresh = true]
         * @param {{create: Function, extract: Function, unify: Function, update: Function|Object, after: ?Function}} params.structural
         * @param {?{unify: Function, update: Function, after: ?Function}} [params.visual = null]
         * @returns {Promise<boolean>}
         */
        static async updateTileListView({isDestroyed, isRefresh = true, structural, visual = null}) {
            /** @type {Object[]} */ let tiles;
            /** @type {Object[]} */ let extractedTiles;

            /**
             * default implementation
             * @param {Help4.widget.help.TileParams[]} tiles
             * @param {Help4.jscore.MutualExclusion} mutual
             * @param {number} mutualToken
             * @param {Help4.control2.container.Container} container
             */
            const updateStructural = (tiles, mutual, mutualToken, container) => {
                if (!mutual.access(mutualToken)) return;

                if (container.count() > 0) container.clean();

                if (tiles.length) {
                    container.add(...tiles);
                    container.visible = true;
                } else {
                    container.visible = false;
                }
            }

            /** @returns {Promise<boolean|null>} */
            const execStructural = async () => {
                const {create, extract, unify, update, after = null} = structural;

                // create new tile params
                tiles = await create();

                // mutual token blocked or destroyed
                if (!tiles || isDestroyed()) return null;

                // in case of refresh: compare new and old data
                // only update the view in case of differences
                if (isRefresh) {
                    extractedTiles = extract();
                    /** @type {Object[]} */ const existingTiles = extractedTiles.map(unify);
                    /** @type {Object[]} */  const newTiles = tiles.map(unify);
                    if (Help4.equalArrays(existingTiles, newTiles)) return false;
                }

                // update view
                if (typeof update === 'function') {
                    update(tiles);
                } else {
                    const {mutual, mutualToken, container} = update;
                    updateStructural(tiles, mutual, mutualToken, container);
                }

                after?.();

                return true;
            }

            /** @returns {boolean} */
            const execVisual = () => {
                if (!visual || !tiles.length || isDestroyed()) return false;

                const {unify, update, after = null} = visual;

                // execVisual will only run in case of refresh
                // otherwise structural update would have happened
                /** @type {Object[]} */ const existingTiles = extractedTiles.map(unify);
                /** @type {Object[]} */  const newTiles = tiles.map(unify);
                if (Help4.equalArrays(existingTiles, newTiles)) return false;

                tiles.forEach((tile, index) => update(tile, index));
                after?.();

                return true;
            }

            // 1. handle structural changes (removed/added tiles)
            // 2. handle visibility and other non-structural changes; but only if no structural update happened
            const result = await execStructural();
            return result === null  ? false : (result || execVisual());
        }

        /**
         * @param {Help4.widget.help.ProjectTile} tile
         * @returns {Help4.data.TileData}
         */
        static tileToTileData(tile) {
            // create tile data from our model information
            /** {@link Help4.engine.hotspotManager.HotspotManagerEngine}; see _getTileDataFromModel */
            /** {@link Help4.data.TileData.prototype.fromModel} */

            /** @type {Help4.data.TileData} */
            const tileData = new Help4.data.TileData(tile);

            const {CATALOGUE_TYPE: UR_CATALOGUE_TYPE} = Help4.widget.help.catalogues.UR;
            if (tile._catalogueType === UR_CATALOGUE_TYPE) {
                /** {@link Help4.service.recording.PlaybackService}; for usage see _getUrHotspotStatus */
                const urEngine = /** @type {Help4.engine.ur.UrHarmonizationEngine} */ Help4.getController().getEngine('urHarmonization');
                tileData.connectUrEngine(urEngine).fromUrEngine(tile.id);
            }

            /** @type {Help4.data.HotspotData} */
            const hotspotData = tileData.hotspot;

            /** {@link Help4.engine.hotspotManager.HotspotManagerEngine}; see _extendHotspotData */
            hotspotData.isPreview = false;  // only for edit mode

            // XRAY-1446, XRAY-1547
            // backwards-compatibility for EXT model: all tiles from RO model are properly extended with values from Help4.DEFAULTS;
            // but the extension from RW model is done minimalistic without using fallbacks
            // it would most likely be better to implement a proper fallback/default handling into EXT model after merge
            // but for now we handle the only value that seems to be problematic here
            hotspotData.controlStyle = hotspotData.hotspotStyle || Help4.DEFAULTS.hotspotStyle.d;

            // correct useOffset property based on maybe modified controlStyle
            const {controlStyle, hotspotOffset, hotspotIconPos} = hotspotData;
            hotspotData.useOffset = controlStyle === 'CIRCLE' && hotspotOffset ||
                controlStyle === 'ICON' && hotspotIconPos === 'R';

            return tileData;
        }

        /**
         * @param {Object} params
         * @param {Help4.typedef.SystemConfiguration} [params.configuration]
         * @param {Help4.widget.Widget.Context} [params.context]
         * @returns {Help4.widget.help.CatalogueKeys}
         */
        static getCatalogueKey({configuration, context}) {
            configuration ||= context.configuration;

            const {isEditorView} = configuration.core;
            return isEditorView ? 'head' : 'pub';
        }
    }
})();