(function() {
/** Selector handling for UR harmonization */
Help4.engine.ur.Selector = class {
/**
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @param {{point: Help4.control2.PositionXY}} param
* @returns {Promise<Object>}
*/
static async pointToSelector(engine, {point}) {
const {_connected, _urHotspots} = engine;
if (_connected) {
const hotspotsAtPoint = [..._urHotspots.values()]
.filter(({hotspotId, position}) => {
// not called for UI5
return position && _atPoint(position, point);
});
if (hotspotsAtPoint.length) {
const {
position: {x, y, width: w, height: h},
_stableId
} = hotspotsAtPoint.sort((a, b) => _compareArea(engine, a, b))[0];
return _finalizeSelectorResult(point, {x, y, w, h}, _stableId);
}
}
return {selector: null};
}
/**
* Playback of user-assigned UR hotspots, not backend help tiles
* matching selector with UR hotspot (migrating if necessary) and returning hotspot data promise
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @param {{selector: Object, useOffset: boolean}} param
* @returns {Promise<Object>}
*/
static async selectorToInfo(engine, {selector, useOffset}) {
const {SameWindowPlayback} = Help4.service.recording;
const result = {action: {}};
const {rule, value} = selector;
let position;
if (rule === 'UrHarmonizationSelector') {
position = _hotspotIdToPosition(engine, value);
} else {
const migrated = Help4.engine.ur.Migration.selectorToUrHotspot(engine, /** @type {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} */ selector) || {};
position = migrated?.position || _hotspotIdToPosition(engine, migrated?.hotspotId);
engine._log?.('CMP: UR info from migrated selector', undefined, ()=>({selector, migrated, position}));
}
if (position) {
const appFrame = engine.getAppFrame();
const appFrameRect = appFrame.getBoundingClientRect();
// set window result
result.window = SameWindowPlayback.urSelectorToInfo({
appFrame,
appFrameRect,
selector,
position,
useOffset,
window: {document: appFrame.ownerDocument}
});
}
return result;
}
/**
* XRAY-5950: Check if we need to hide the backend (UR) hotspot/tile because we already have a new assignment for this element.
* - CMP4 - {@link Help4.widget.companionCore.data.Help.filterUrTiles}
* - CMP3 - {@link Help4.controller.Handler.setTiles}
* @param {string} hotspotId
* @param {Set<string>} assignedSelectors - list of assigned UI5 selectors
* @return {boolean} true if the hotspot is close to a UI5 element that already has an assignment
*/
static isAssignedInUI5(hotspotId, assignedSelectors) {
const {methods} = Help4.selector;
for (const {rule, value} of assignedSelectors) {
const assigned = methods[rule]?.getElement.call(this, value);
if (!assigned) continue;
const urElement = assigned.closest(`#${methods.$E(hotspotId)}`);
// XRAY-6532: UR hotspot element can be a container (e.g. table)
// allow some padding for assigned hotspots to be inside backend hotspots, otherwise they are not considered the same
if (urElement && _similarRects(assigned.getBoundingClientRect(), urElement.getBoundingClientRect())) {
return true;
}
// remove -text, -inner, etc.
const outerElementId = hotspotId.replace(/-(bdi|content|inner|text)$/, '');
if (assigned.id.endsWith('-wrapperfor-' + outerElementId)) return true;
const outerElement = outerElementId !== hotspotId && assigned.closest(`#${methods.$E(outerElementId)}`);
if (outerElement && _similarRects(assigned.getBoundingClientRect(), outerElement.getBoundingClientRect())) {
return true;
}
}
return false;
}
}
/**
* @private
* @param {DOMRect} position
* @param {Help4.control2.PositionXY} point
* @returns {*}
*/
function _atPoint(position, point) {
const {x, y, width: w, height: h} = position;
return Help4.isPointInRect(point, {
x: Math.floor(x),
y: Math.floor(y),
w: Math.ceil(w),
h: Math.ceil(h)
});
}
/**
* @private
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @param {{position: DOMRect, _urId: string}} area1
* @param {{position: DOMRect, _urId: string}} area2
* @returns {number}
*/
function _compareArea(engine, {position: {width: w1, height: h1}, _urId: id1}, {position: {width: w2, height: h2}, _urId: id2}) {
const areaDiff = (w1 * h1) - (w2 * h2);
// prefer smaller hotspots or if they are the same size, hotspots with backend text,
// so they can be overwritten by the user with new assignments
if (areaDiff) return areaDiff;
const {_texts} = engine;
const text1 = !!_texts[id1];
const text2 = !!_texts[id2];
return text1 && !text2
? -1
: (!text1 && text2 ? 1 : 0);
}
/**
* check rectangles for similarity with maximum difference of maxDelta
* @param {DOMRect} inner
* @param {DOMRect} outer
* @param {number} [maxDeltaX]
* @param {number} [maxDeltaY]
* @return {boolean}
* @private
*/
function _similarRects(inner, outer, maxDeltaX = 40, maxDeltaY = 10) {
const {left, right, top, bottom} = inner;
const {left: oLeft, right: oRight, top: oTop, bottom: oBottom} = outer;
if (left - oLeft < maxDeltaX && oRight - right < maxDeltaX && top - oTop < maxDeltaY && oBottom - bottom < maxDeltaY) {
return true;
}
}
/**
* find position by hotspotId
* @private
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @param id
* @returns {?DOMRect}
*/
function _hotspotIdToPosition(engine, id) {
for (const {_stableId, position} of engine._urHotspots.values()) { // values().find is still experimental
if (id === _stableId) return position;
}
return null;
}
/**
* @private
* @param {Help4.control2.PositionXY} point
* @param {Help4.control2.AreaXYWH} rect
* @param {string} id
* @returns {Object}
*/
function _finalizeSelectorResult({x: px, y: py}, rect, id) {
// mouse position within selected elem
const x = Math.round(((px - rect.x) / rect.w ) * 10000) / 10000;
const y = Math.round(((py - rect.y) / rect.h) * 10000) / 10000;
// different to UrHarmonizationTileSelector
// which is only used for backend help playback, not for assigned hotspots
return {
selector: {
rule: 'UrHarmonizationSelector',
offset: {x, y},
value: id
},
info: {
element: {id, nodeName: 'UR-HOTSPOT', rect},
selector: {quality: Help4.selector.SELECTOR_QUALITY.best}
}
};
}
})();