Source: widget/Widget.js

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

    /** @type string[] */
    const STATUS_KEYS = ['init', 'started', 'active'];
    /**
     * @enum {string}
     * @property {'init'} init
     * @property {'started'} started
     * @property {'active'} active
     */
    const STATES = Help4.BinaryStorage.createPublicMap(STATUS_KEYS);
    const CONTEXT_CACHE_TIME = 100;
    const WAIT_TIME = 100;

    /**
     * @typedef {Object} Help4.widget.Widget.Context
     * @property {Help4.controller.Controller} controller
     * @property {Help4.EventBus} eventBus
     * @property {?Help4.control2.bubble.Panel} panel
     * @property {?Help4.typedef.SystemConfiguration} configuration
     * @property {Object} engine
     * @property {?Help4.engine.DomRefreshEngine} engine.domRefreshEngine
     * @property {?Help4.engine.crossorigin.CoreEngine} engine.crossOriginEngine
     * @property {Object} service
     * @property {?Help4.service.recording.PlaybackService} service.playbackService
     * @property {?Help4.service.recording.PlaybackCacheService} service.playbackCacheService
     * @property {?Help4.service.CrossOriginMessageService} service.crossOriginService
     * @property {Help4.service.ConditionService} service.conditionService
     * @property {Help4.service.InfobarService} service.infobarService
     * @property {Help4.service.LightboxService} service.lightboxService
     * @property {Help4.service.HotkeyService} service.hotkey
     * @property {Object} widget
     */

    /**
     * @typedef {Object} Help4.widget.Widget.SearchFilter
     * @param {string} fulltext
     */

    /**
     * @typedef {Object} Help4.widget.Widget.SearchResult
     // generic
     * @param {string} caption
     * @param {string} [description]
     * @param {string} [contentLanguage]
     // tour playback
     * @param {string} [projectId]
     * @param {Help4.widget.help.CatalogueKeys} [catalogueKey]
     * @param {Help4.widget.help.CatalogueTypes} [catalogueType]
     * @param {Help4.widget.help.DataTypes} [dataType]
     // learning
     * @param {string} [entityType]
     * @param {string} [entitySubType]
     * @param {string} [entityUid]
     // help, whatsnew
     * @param {string} type
     * @param {string} icon
     * @param {boolean} showAsButton
     */

    /**
     * @typedef {Object} Help4.widget.Widget.SerializedData
     * @property {*} [full]
     * @property {*} [interaction]
     */

    /**
     * @typedef {Object} Help4.widget.Widget.SerializedStatus
     * @property {Object} full
     * @property {string} full.name
     * @property {boolean} full.started
     * @property {boolean} full.active
     * @property {boolean} full.visible
     * @property {*} full.data
     * @property {Object} interaction
     * @property {string} interaction.name
     * @property {*} interaction.data
     */

    /**
     * @typedef {Object} Help4.widget.Widget.Event
     * @property {string} type - event type
     * @property {Help4.widget.Widget} engine - widget engine
     * @property {*} value - any data to be transferred
     * @property {*} data - any data to be transferred
     * @property {Object} [descriptor] - additional widget information
     */

    /**
     * @typedef {Object} Help4.widget.Widget.Descriptor
     * @property {string} id - widget id
     * @property {boolean} enabled - whether widget is enabled
     * @property {boolean} showPanel - whether this widget needs the panel
     * @property {Help4.widget.Requirement.Requirements} [requires] - requirements for widget start
     * @property {string[]} [autostart] - autostart this widget of all listed ones are started
     * @property {Object} [tile] - tile descriptor (for panel)
     * @property {number} [tile.position] - position in panel; works as categories. if more than one widget has the same position they will share that space
     * @property {string} [tile.text] - text of the widget tile
     * @property {string} [tile.title] - title of the widget tile
     * @property {Help4.control2.ICONS|string} [tile.icon] - icon of the widget tile
     * @property {Help4.widget.COLOR|string} [tile.color] - color of the widget tile; {@link Help4.widget.COLOR}
     * @property {boolean} [tile.visible] -  whether the tile is visible
     */

    /**
     * Basic widget class.
     * @augments Help4.jscore.ControlBase
     * @abstract
     * @property {boolean} __visible
     * @property {Help4.widget.Widget.Context} _context
     * @property {?number} _contextTS
     * @property {Help4.widget.Widget.Descriptor} _descriptor
     * @property {Help4.observer.EventBusObserver} _eventBusObserver
     * @property {Object} _eventStatus
     * @property {Help4.BinaryStorage} _state
     * @property {boolean} _toBeActivated
     */
    Help4.widget.Widget = class extends Help4.jscore.ControlBase {
        /**
         * @override
         * @param {Help4.jscore.ControlBase.Params} [derived]
         */
        constructor(derived) {
            const {BinaryStorage, observer: {EventBusObserver}} = Help4;

            const {TYPES: T} = Help4.jscore.ControlBase;
            super({}, {
                params: {
                    visible: {type: T.boolean, private: true}
                },
                statics: {
                    _context:          {destroy: false},
                    _contextTS:        {destroy: false},
                    _descriptor:       {destroy: false},
                    _eventBusObserver: {init: new EventBusObserver(event => _onEventBus.call(this, event))},
                    _eventStatus:      {init: {}, destroy: false},
                    _state:            {init: new BinaryStorage(STATUS_KEYS)},
                    _toBeActivated:    {init: false, destroy: false}
                },
                config: {
                    onPropertyChange: event => void this._onPropertyChange(event)
                },
                derived
            });

            Help4.widget.assertSingleton(this.getName());

            this.start();
        }

        /**
         * @returns {string} unique name of widget
         * @abstract
         */
        getName() {
            throw new Error('getName() needs to be overridden!');
        }
        /**
         * @returns {Promise<void>}
         * @protected
         */
        async _onContextAvailable() {}
        /**
         * @returns {Promise<Help4.widget.Widget.Descriptor>}
         * @protected
         * @throws {Error}
         * @abstract
         */
        async _onGetDescriptor() {
            throw new Error('_onGetDescriptor() needs to be overridden!');
        }
        /**
         * @protected
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeInit() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onAfterInit() {}
        /**
         * @protected
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeDestroy() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onAfterDestroy() {}
        /**
         * @protected
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeStart() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onAfterStart() {}
        /**
         * @protected
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeStop() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onAfterStop() {}
        /**
         * @protected
         * @param {Object} [data = null]
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeActivate(data = null) {}
        /**
         * @protected
         * @param {Object} [data = null]
         * @returns {Promise<void>}
         */
        async _onAfterActivate(data = null) {}
        /**
         * @protected
         * @param {*} [data = null]
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeDeactivate(data = null) {}
        /**
         * @protected
         * @param {*} [data = null]
         * @returns {Promise<void>}
         */
        async _onAfterDeactivate(data = null) {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onSystemNavigate() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onControllerOpen() {}
        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _onControllerClose() {}
        /**
         * @protected
         * @param {boolean} value
         * @returns {Promise<void>}
         */
        async _onControllerEditMode(value) {}
        /**
         * @protected
         * @param {boolean} value
         * @returns {Promise<void>}
         */
        async _onControllerPlaybackActive(value) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @returns {Promise<void>}
         */
        async _onWidgetStart(widget) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @returns {Promise<void>}
         */
        async _onWidgetStop(widget) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @returns {Promise<void>}
         */
        async _onWidgetActivate(widget) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @param {*} [data]
         * @returns {Promise<void>}
         */
        async _onWidgetDeactivate(widget, data) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @returns {Promise<void>}
         */
        async _onWidgetVisible(widget) {}
        /**
         * @protected
         * @param {Help4.widget.Widget} widget
         * @returns {Promise<void>}
         */
        async _onWidgetInvisible(widget) {}
        /**
         * @protected
         * @returns {Promise<Help4.widget.Widget.SerializedData|false>}
         */
        async _onSerialize() {
            return {};
        }

        /** accessibility: implement to all to focus the current widget */
        focus() {}

        /**
         * accessibility: implement to all to focus the list item tile using up/down arrow keys
         * @param {string} direction
         */
        focusListItem(direction) {}

        /** @returns {Promise<void>} */
        async updateData() {
            this._cleanInfobar();
            return this._onSystemNavigate();
        }

        /** @returns {Promise<boolean>} */
        awaitStarted() {
            return new Help4.Promise(resolve => {
                const check = () => {
                    if (this.isStarted()) return resolve(true);
                    if (this.isDestroyed()) return resolve(false);
                    setTimeout(check, WAIT_TIME);
                }
                check();
            });
        }

        /** @returns {Promise<boolean>} */
        awaitActivated() {
            return new Help4.Promise(resolve => {
                const check = () => {
                    if (this.isActive()) return resolve(true);
                    if (this.isDestroyed()) return resolve(false);
                    setTimeout(check, WAIT_TIME);
                }
                check();
            });
        }

        /**
         * returns whether widget is visible
         * @returns {boolean}
         */
        isVisible() {
            return this.__visible;
        }

        /**
         * returns whether widget is started
         * @returns {boolean}
         */
        isStarted() {
            return this._state?.has(STATES.started) || false;
        }

        /**
         * returns whether widget is active
         * @returns {boolean}
         */
        isActive() {
            return this._state?.has(STATES.active) || false;
        }

        /**
         * returns widget descriptor
         * @returns {Help4.widget.Widget.Descriptor}
         */
        getDescriptor() {
            return this._descriptor;
        }

        /**
         * returns system context
         * @returns {Help4.widget.Widget.Context}
         */
        getContext() {
            const now = new Date().getTime();
            return now - this._contextTS < CONTEXT_CACHE_TIME
                ? this._context
                : _createContext.call(this);
        }

        /**
         * used to retrieve filter results
         * @param {Help4.widget.Widget.SearchFilter} search
         * @returns {Promise<Help4.widget.Widget.SearchResult[]>}
         */
        async filter(search) {
            return [];
        }

        /**
         * for use with filter: checks whether the given fulltext matches a text
         * @param {string} fulltext
         * @param {string} text
         * @returns {boolean}
         * @protected
         */
        _fulltextMatchesText(fulltext, text)  {
            return (text || '').toLowerCase().indexOf(fulltext) >= 0;
        }

        /**
         * for use with filter: checks whether the given fulltext matches a HTML
         * @param {string} fulltext
         * @param {string} html
         * @returns {boolean}
         * @protected
         */
        _fulltextMatchesHtml(fulltext, html) {
            return html && Help4.filterHtml(html, fulltext) || false;
        }

        /**
         * used to retrieve a list of all visible texts for the current widget
         * @returns {Promise<?Object>}
         */
        async getTexts() {}

        /**
         * used to replace all visible texts for the current widget
         * @param {Object} texts
         * @returns {Promise<void>}
         */
        async setTexts(texts) {}

        /**
         * registers the panel instance within this widget
         * @param {?Help4.control2.bubble.Panel} panel
         */
        registerPanel(panel) {
            if (this._panel = panel) {
                if (this._toBeActivated) {
                    this._toBeActivated = false;  // cleanup the flag
                    this.activate();
                }
            }
        }

        /**
         * called when each widget should consider to redraw itself
         * @returns {Promise<void>}
         */
        async redraw() {
            _createContext.call(this);
        }

        /** @returns {Promise<void>} */
        async destroy() {
            if (await this._onBeforeDestroy() === false) return;
            await this.stop(true);

            delete this._panel;

            const {INSTANCES} = Help4.widget;
            const index = INSTANCES.indexOf(this);
            if (index >= 0) INSTANCES.splice(index, 1);

            super.destroy();

            await this._onAfterDestroy();
        }

        /** @returns {Promise<void>} */
        async start() {
            const {_state} = this;

            if (!_state.has(STATES.init)) {
                try {
                    if (!await _init.call(this)) {
                        // init not successful; either error or widget not enabled

                        // remove widget from to-be-integrated list
                        const {INTEGRATION} = Help4.widget.Infrastructure;
                        INTEGRATION && (delete INTEGRATION[this.getName()]);

                        // destroy widget immediately
                        return void this.destroy();
                    }
                } catch(e) {
                    console.error(e);
                    return void this.destroy();  // exception during init
                }
            }

            if (this.isStarted()) return;  // already started

            // do not start if requirements no longer met; a required widgetInstance has been shut down
            const {requires, autostart} = this._descriptor;
            const missing = requires ? Help4.widget.Requirement.allRequiredWidgetsStarted(requires) : [];
            if (missing.length) return void console.error(`Cannot start widget - required widget instance(s) not started: "${missing.join('", "')}"`);

            if (await this._onBeforeStart() === false) return;

            const {eventBus} = this.getContext();
            const {TYPES} = eventBus;
            const {_eventBusObserver} = this;
            _state.add(STATES.started);
            eventBus.fire(/** @type {Help4.widget.Widget.Event} */ {
                type: TYPES.widgetStart,
                engine: this,
                value: true,
                descriptor: this._descriptor
            });
            await this._setStatus();
            _eventBusObserver.observe(eventBus, {
                type: [
                    TYPES.controllerOpen,
                    TYPES.controllerClose,
                    TYPES.controllerAfterNavigate,
                    TYPES.controllerEditMode,
                    TYPES.controllerPlaybackActive,
                    TYPES.widgetStart,
                    TYPES.widgetActivate,
                    TYPES.widgetVisibility
                ]
            });

            await this._onAfterStart();
        }

        /**
         * @param {boolean} [force = false]
         * @returns {Promise<void>}
         */
        async stop(force = false) {
            const {_state} = this;

            if (!this.isStarted()) return;  // not started

            await this.deactivate();
            if (await this._onBeforeStop() === false && !force) return;

            const {eventBus} = this.getContext();
            const {_eventBusObserver} = this;
            _state.rem(STATES.started);
            await this._setStatus();
            _eventBusObserver.disconnect();
            eventBus.fire(/** @type {Help4.widget.Widget.Event} */ {
                type: eventBus.TYPES.widgetStart,
                engine: this,
                value: false,
                descriptor: this._descriptor
            });

            await this._onAfterStop();
        }

        /**
         * @param {Object} [data = null]
         * @returns {Promise<void>}
         */
        async activate(data = null) {
            const {_state} = this;

            if (!this.isStarted() || !this.isVisible()) return;  // not applicable
            if (this.isActive()) return;  // already activated

            if (!this._panel) return void (this._toBeActivated = true);  // activation w/o panel is not possible

            // deactivate another active widget
            const activeWidget = Help4.widget.getActiveInstance();
            if (activeWidget) await activeWidget.deactivate({next: this});

            // recreate context
            _createContext.call(this);

            if (await this._onBeforeActivate(data) === false) return;

            _state.add(STATES.active);

            const {eventBus} = this.getContext();
            const {TYPES: {widgetActivate: type}} = Help4.EventBus;
            eventBus.fire(/** @type {Help4.widget.Widget.Event} */ {
                type,
                engine: this,
                value: true
            });
            await this._setStatus();
            await this._onAfterActivate(data);

            await Help4.widget.trackOpenClose(this, {verb: 'open'});
        }

        /**
         * @param {*} [data = null]
         * @returns {Promise<void>}
         */
        async deactivate(data = null) {
            const {_state} = this;

            if (!this.isActive()) return;  // not activated
            if (await this._onBeforeDeactivate(data) === false) return;

            const {
                eventBus,
                service: {/** @type {Help4.service.LightboxService} */ lightboxService}
            } = this.getContext();

            lightboxService.clean();
            _state.rem(STATES.active);
            await Help4.widget.trackOpenClose(this, {verb: 'close'});
            await this._setStatus();

            const {TYPES: {widgetActivate: type}} = eventBus;
            eventBus.fire(/** @type {Help4.widget.Widget.Event} */ {
                type,
                engine: this,
                value: false,
                data
            });

            await this._onAfterDeactivate(data);
        }

        /** @returns {Promise<Help4.widget.Widget.SerializedStatus|false>} */
        async serialize() {
            // full status: complete status information of a widget
            // interaction status: only information that is based on user interaction
            // in case persistence is disabled the interaction status will still be stored
            const name = this.getName();
            const started = this.isStarted();
            const active = this.isActive();
            const visible = this.isVisible();

            const result = /** @type {Help4.widget.Widget.SerializedData|false} */ await this._onSerialize();
            if (this.isDestroyed() || result === false) return false;  // abort; no valid status

            const full = {name, started, active, visible, data: result.full || null}
            const interaction = {name, data: result.interaction || null};
            return {full, interaction};
        }

        /**
         * @param {Help4.jscore.ControlBase.PropertyChangeEvent} event - the change event
         * @returns {Promise<void>}
         * @protected
         */
        async _onPropertyChange({name, value}) {
            if (name === 'visible') {
                await this._setStatus();

                if (!value) await this.deactivate();

                const {TYPES} = Help4.EventBus;
                const {eventBus} = this.getContext();
                eventBus.fire(/** @type {Help4.widget.Widget.Event} */ {
                    type: TYPES.widgetVisibility,
                    engine: this,
                    value
                });
            }
        }

        /**
         * stores widget state
         * @memberof Help4.widget.Widget#
         * @protected
         * @returns {Promise<void>}
         */
        async _setStatus() {
            const status = /** @type {Help4.widget.Widget.SerializedStatus|false} */ await this.serialize();
            if (this.isDestroyed() || status === false) return;  // abort; status invalid

            const name = this.getName();
            const context = this.getContext();
            await Help4.widget.companionCore.State.set(name, status, context);
        }

        /** @protected */
        _cleanInfobar() {
            const {
                /** @type {Help4.service.InfobarService} */ infobarService
            } = this.getContext().service;

            infobarService.clean();
        }
    }

    /**
     * @memberof Help4.widget.Widget#
     * @returns {Promise<boolean>} - whether widget is enabled
     * @private
     */
    async function _init() {
        _createContext.call(this);
        await this._onContextAvailable();

        this._descriptor = /** @type {Help4.widget.Widget.Descriptor} */ await this._onGetDescriptor();
        if (!this._descriptor) throw new Error('Descriptor is needed for a widget!');

        const {enabled = false, requires} = this._descriptor;
        if (!enabled) return false;

        if (requires) {
            /** @type {Help4.widget.Widget.Context} */ const context = this.getContext();
            const success = await Help4.widget.Requirement.awaitRequirements(requires, context);
            if (!success) return false;
        }

        if (await this._onBeforeInit() === false) return false;

        this._state.add(STATES.init);
        Help4.widget.INSTANCES.push(this);

        await this._onAfterInit();
        return true;
    }

    /**
     * @memberof Help4.widget.Widget#
     * @returns {Help4.widget.Widget.Context}
     * @private
     */
    function _createContext() {
        const {/** @type {?Help4.control2.bubble.Panel} */ _panel: panel} = this;
        /** @type {Help4.controller.Controller} */ const controller = Help4.getController();
        /** @type {Help4.typedef.SystemConfiguration} */ const configuration = controller.getConfiguration();
        /** @type {Help4.engine.DomRefreshEngine} */ const domRefreshEngine = controller.getEngine('domRefresh');
        /** @type {Help4.engine.crossorigin.CoreEngine} */ const crossOriginEngine = controller.getEngine('crossOrigin');

        const {
            /** @type {Help4.service.recording.PlaybackService} */ playback: playbackService,
            /** @type {Help4.service.recording.PlaybackCacheService} */ playbackCache: playbackCacheService,
            /** @type {Help4.service.CrossOriginMessageService} */ crossOrigin: crossOriginService,
            /** @type {Help4.service.ConditionService} */ condition: conditionService,
            /** @type {Help4.service.InfobarService} */ infobar4: infobarService,
            /** @type {Help4.service.LightboxService} */ lightbox4: lightboxService,
            /** @type {Help4.EventBus} */ eventBus,
            /** @type {Help4.service.HotkeyService} */ hotkey
        } = controller.getService('playback', 'playbackCache', 'crossOrigin', 'eventBus', 'condition', 'infobar4', 'lightbox4', 'hotkey');

        this._contextTS = new Date().getTime();
        return this._context = {
            controller,
            eventBus,
            panel,
            configuration,
            engine: {
                domRefreshEngine,
                crossOriginEngine
            },
            service: {
                playbackService,
                playbackCacheService,
                crossOriginService,
                conditionService,
                infobarService,
                lightboxService,
                hotkey
            },
            widget: {}
        }
    }

    /**
     * @memberof Help4.widget.Widget#
     * @private
     * @param {Help4.widget.Widget.Event} event
     * @returns {Promise<void>}
     */
    async function _onEventBus({engine, type, value, data}) {
        const {TYPES} = Help4.EventBus;
        const {_eventStatus} = this;

        switch (type) {
            case TYPES.widgetStart:
                if (engine !== this) {
                    value
                        ? await this._onWidgetStart(engine)
                        : await this._onWidgetStop(engine);

                    if (value) {
                        // check autostart
                        await Help4.widget.Infrastructure.autostart();
                    } else {
                        // auto stop in case a required instance has been stopped
                        // warning: will not be auto-started again!
                        /** exception: see {@link Help4.widget.Infrastructure.autostart} */
                        const {requires} = this._descriptor;
                        const missing = requires ? Help4.widget.Requirement.allRequiredWidgetsStarted(requires) : [];
                        !missing.length || this.stop();
                    }
                }
                break;

            case TYPES.widgetActivate:
                if (engine !== this) {
                    value
                        ? await this._onWidgetActivate(engine)
                        : await this._onWidgetDeactivate(engine, data);
                }
                break;

            case TYPES.widgetVisibility:
                if (engine !== this) {
                    value
                        ? await this._onWidgetVisible(engine)
                        : await this._onWidgetInvisible(engine);
                }
                break;

            case TYPES.controllerAfterNavigate:
                _createContext.call(this);  // refresh context
                this._cleanInfobar();
                await this._onSystemNavigate();
                break;

            case TYPES.controllerOpen:
                _createContext.call(this);  // refresh context
                await this._onControllerOpen();
                break;

            case TYPES.controllerClose:
                _createContext.call(this);  // refresh context
                await this._onControllerClose();
                break;

            case TYPES.controllerEditMode:
                if (_eventStatus.controllerEditMode !== value) {
                    _eventStatus.controllerEditMode = value;
                    _createContext.call(this);  // refresh context
                    await this._onControllerEditMode(value);
                }
                break;

            case TYPES.controllerPlaybackActive:
                if (_eventStatus.controllerPlaybackActive !== value) {
                    _eventStatus.controllerPlaybackActive = value;
                    _createContext.call(this);  // refresh context
                    await this._onControllerPlaybackActive(value);
                }
                break;
        }
    }
})();