Source: engine/ur/Selector.js

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