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