(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;
}
}
})();