Source: control2/bubble/BubbleAlignment.js

(function() {
    const NS = Help4.control2.bubble;

    /**
     * @typedef {Object} Help4.control2.bubble.OriMap
     * @property {'top'} N
     * @property {'bottom'} S
     * @property {'left'} W
     * @property {'right'} E
     * @property {'middle'} M
     * @property {'center'} C
     */

    /**
     * @typedef {Object} Help4.control2.bubble.AlignPointsParams
     * @property {Help4.control2.ConnectionPoints} connectionPoints
     * @property {boolean} ignoreArea
     * @property {string} orientation
     * @property {boolean} allowFallbacks
     */

    /**
     * @method
     * @param {Help4.control2.AreaXYWH} position
     */
    Help4.control2.bubble.alignToArea = _alignToArea;

    /**
     * @method
     * @param {Help4.control2.bubble.AlignPointsParams} params
     * @returns {Help4.control2.bubble.Alignment}
     */
    Help4.control2.bubble.alignToPoints = _alignToPoints;

    /**
     * @method
     */
    Help4.control2.bubble.Bubble.prototype.resetAlignmentCache = _resetAlignmentCache;


    /**
     * @const
     * @type {'__alignmentCache'}
     */
    Help4.control2.bubble.ALIGNMENT_CACHE_KEY = '__alignmentCache';

    /**
     * @const
     * @type {Help4.control2.bubble.OriMap}
     */
    Help4.control2.bubble.ORI_MAP = {
        N: 'top',
        S: 'bottom',
        W: 'left',
        E: 'right',
        M: 'middle',
        C: 'center'
    };

    const ALIGNMENT = {
        left: 'left',
        right: 'right',
        top: 'top',
        bottom: 'bottom',
        center: 'center',
        screen: 'screen',
        middle: 'middle',
        'fit-h': 'fit-h',
        'fit-screen': 'fit-screen'
    };

    const PRIORITY = {
        true: [ALIGNMENT.left, ALIGNMENT.bottom, ALIGNMENT.top, ALIGNMENT.right, ALIGNMENT.middle],  // RTL
        false: [ALIGNMENT.right, ALIGNMENT.bottom, ALIGNMENT.top, ALIGNMENT.left, ALIGNMENT.middle]  // LTR
    };

    const MIN_SCROLL_HEIGHT = 100;  // minimum height of scrollable bubble
    const ARROW_MULT = 1.5;  // // how much space (measured in arrowSize) to be reserved on bubble corners

    // runs in scope of bubble
    /**
     * @param {Help4.control2.AreaXYWH} position
     */
    function _alignToArea(position) {
        const style = {
            left: position.x + 'px',
            top: position.y + 'px'
        };
        if (position.w != null) style.width = position.w + 'px';
        if (position.h != null) style.height =position.h + 'px';
        this.setStyle(style);

        if (this.showAfterAlign) this.visible = true;
    }

    // runs in scope of bubble
    function _resetAlignmentCache() {
        this[NS.ALIGNMENT_CACHE_KEY] = null;
    }

    // runs in scope of bubble
    /**
     * @param {Help4.control2.bubble.AlignPointsParams} params
     * @returns {Help4.control2.bubble.Alignment}
     */
    function _alignToPoints(params) {
        // use the bubble for cache comparison before reset
        _getAvailableArea.call(this, params);

        let cache = this[NS.ALIGNMENT_CACHE_KEY];
        if (cache) {
            const conditions = cache.conditions;
            if (Help4.equalObjects(conditions.connectionPoints, params.connectionPoints) &&
                Help4.equalObjects(conditions.availableArea, params.availableArea) &&
                conditions.orientation === params.orientation)
            {
                return cache.result;
            }
        }

        _reset.call(this);
        _prepare.call(this, params);

        cache = this[NS.ALIGNMENT_CACHE_KEY] = {
            conditions: {
                connectionPoints: Help4.cloneValue(params.connectionPoints),
                availableArea: Help4.cloneValue(params.availableArea),
                orientation: params.orientation
            }
        };

        const best = _calcBestOrientation.call(params);
        let result = {};

        if (best) {
            result.point = best.point;

            this.setStyle({
                left: best.x + 'px',
                top: best.y + 'px'
            });
            this.addCss(best.orientation);

            if (this._arrow) {
                this._arrow.setStyle({
                    marginLeft: best.arrowX ? best.arrowX + 'px' : '',
                    marginTop: best.arrowY ? best.arrowY + 'px' : '',
                });
            }

            // convert back into format that we received
            result.orientation = best.orientation.charAt(0);
        } else {
            // nothing worked, bubble is simply too big for current screen; squeeze it in
            this.addCss(result.orientation = ALIGNMENT['fit-screen']);
        }

        if (this.showAfterAlign) {
            this.visible = true;
        }

        return cache.result = result;
    }

    // runs in scope of bubble
    function _reset() {
        this.removeCss.apply(this, Object.keys(ALIGNMENT));

        if (this.resizableAlign && this._content) {
            this._content.setStyle({height: ''});
        }
    }

    // runs in scope of bubble
    function _getAvailableArea(params) {
        const container = this.container;
        params.availableArea = !params.ignoreArea && container && container.getArea
            && container.getArea()
            || {x: 0, y: 0, w: window.innerWidth, h: window.innerHeight};
    }

    // runs in scope of bubble
    function _prepare(params) {
        const bubbleDom = params.bubbleDom = this.getDom();
        params.bubbleSize = {
            w: bubbleDom.offsetWidth,
            h: bubbleDom.offsetHeight
        }

        if (this.resizableAlign && this._content) {
            const contentDom = params.contentDom = this._content.getDom();
            params.contentSize = {
                w: contentDom.offsetWidth,
                h: contentDom.offsetHeight
            }
        }

        const arrowDom = this._arrow && this._arrow.getDom();
        params.arrowSize = arrowDom ? arrowDom.offsetWidth >> 1 : 0;

        params.rtl = this.rtl;
    }

    // runs in scope of info (see _alignToPoints)
    function _calcBestOrientation() {
        _setPriority.call(this);             // define orientation priority based on LTR / RTL
        _filterPoints.call(this);            // filter out all points that are outside of area
        _convertCenterOri.call(this);        // change center to be left,top,right,bottom
        return _findAlignment.call(this);  // calculate the best orientation
    }

    // runs in scope of info (see _alignToPoints)
    function _setPriority() {
        // define orientation priority based on LTR / RTL
        this.priority = PRIORITY[this.rtl].slice();  // fast copy by value
        delete this.rtl;

        // XRAY-1054: user defined orientation; move to front
        if (this.orientation && this.orientation !== 'auto') {
            const o = NS.ORI_MAP[this.orientation];
            this.priority.splice(this.priority.indexOf(o), 1);
            this.priority.unshift(o);
        }
    }

    // runs in scope of info (see _alignToPoints)
    function _filterPoints() {
        const filtered = {};

        for (const [ori, point] of Object.entries(this.connectionPoints)) {
            const inRect = [];

            if (Help4.isArray(point)) {
                for (const pt of point) {
                    if (Help4.isPointInRect(pt, this.availableArea)) inRect.push(pt);
                }
            } else if (Help4.isPointInRect(point, this.availableArea)) {
                inRect.push(point);
            }

            if (inRect.length) filtered[ori] = inRect;
        }

        this.connectionPoints = filtered;
    }

    // runs in scope of info (see _alignToPoints)
    function _convertCenterOri() {
        // special case: align to a center point - all positions are possible
        // clone midpoint to l,t,r,b and try; return best match
        // XRAY-1425; XRAY-2125
        const c = this.connectionPoints.c;
        if (c) this.connectionPoints = {l: c, t: c, r: c, b: c, m: c};
    }

    // runs in scope of info (see _alignToPoints)
    function _findAlignment() {
        let index = 0;
        let alignment;

        // this.connectionPoints contains arrays of possible connection points per orientation
        // they are ordered by index, entries with lower index have higher priority
        // move through the points in order of index/ priority and apply all alignment methods
        while (1) {
            const connectionPoints = _getConnectionPoints.call(this, index++);
            if (!connectionPoints) break;  // no more points to process

            // 1st phase: try to position bubble in normal size at connection point
            if (alignment = _alignSameSize.call(this, connectionPoints)) return alignment;

            // 2nd phase: try to position bubble full width but with scrollable height
            if (this.contentSize) {  // only if scrollable content exists
                alignment = this.orientation && this.orientation !== 'auto'
                    ? _alignScrollableUserDefined.call(this, connectionPoints)  // XRAY-1054: prioritize user defined orientation
                    : _alignScrollableBestPossible.call(this, connectionPoints);
                if (alignment) return alignment;
            }
        }

        // 3rd phase (fallback): align bubble centered; if not forbidden by setting
        if (this.allowFallbacks) {
            if (alignment = _alignCentered.call(this)) return alignment;
        }

        return null;
    }

    // runs in scope of info (see _alignToPoints)
    function _getConnectionPoints(index) {
        const r = {};
        for (const [ori, pt] of Object.entries(this.connectionPoints)) {
            if (pt[index]) r[ori] = pt[index];
        }
        return Object.keys(r).length ? r : null;
    }

    // runs in scope of info (see _alignToPoints)
    function _alignSameSize(connectionPoints) {
        const arrowSpace = this.arrowSize * ARROW_MULT;

        function getVertical(ori, point) {
            // align with vertical flexibility
            const bubblePos = _getBubblePosition.call(this, ori, point);
            const p1 = {x: bubblePos.x, y: point.y - arrowSpace};  // from pt down
            const p2 = {x: bubblePos.x, y: point.y + arrowSpace - this.bubbleSize.h};  // from pt up
            const y = _getBestFit.call(this, {mode: 'y', p1: p1, p2: p2});
            return y ? {x: bubblePos.x, y: y, arrowY: point.y - y - this.arrowSize} : null;
        }

        function getHorizontal(ori, point) {
            // align with horizontal flexibility
            const bubblePos = _getBubblePosition.call(this, ori, point);
            const p1 = {x: point.x - arrowSpace, y: bubblePos.y};  // from pt to right
            const p2 = {x: point.x + arrowSpace - this.bubbleSize.w, y: bubblePos.y};  // from pt to left
            const x = _getBestFit.call(this, {mode: 'x', p1: p1, p2: p2});
            return x ? {x: x, y: bubblePos.y, arrowX: point.x - x - this.arrowSize} : null;
        }

        function getMiddle(point) {
            // special case for DA; XRAY-2731
            const p =  {  // midpoint
                x: point.x - (this.bubbleSize.w >> 1),
                y: point.y - (this.bubbleSize.h >> 1)
            };
            const x = _getBestFit.call(this, {mode: 'x', p1: p, p2: p});
            const y = _getBestFit.call(this, {mode: 'y', p1: p, p2: p});
            return x && y ? {x: x, y: y} : null;
        }

        const funMap = {l: getVertical, r: getVertical, m: getMiddle, t: getHorizontal, b: getHorizontal};

        for (const priority of this.priority) {
            const ori = priority.charAt(0);
            const point = connectionPoints[ori];
            if (!point) continue;

            const alignment = funMap[ori].call(this, ori, point)
            if (alignment) {
                alignment.orientation = priority;
                alignment.point = point;
                return alignment;
            }
        }

        return null;
    }

    // runs in scope of info (see _alignToPoints)
    function _getBubblePosition(ori, point, bubbleSize = this.bubbleSize) {
        const as = this.arrowSize;
        return {
            x: ori === 'l'
                ? point.x - as - bubbleSize.w  // align in front of point
                : point.x + as,        // align behind point

            y: ori === 't'
                ? point.y - as - bubbleSize.h  // align on top of point
                : point.y + as         // align below point
        };
    }

    // runs in scope of info (see _alignToPoints)
    function _getBestFit(params) {
        const dim = params.mode === 'y'
            ? {x: 'x', y: 'y', w: 'w', h: 'h'}
            : {x: 'y', y: 'x', w: 'h', h: 'w'};

        const aa = this.availableArea;
        const bs = this.bubbleSize;
        const p1 = params.p1;  // p1 is bubble from point down/left
        const p2 = params.p2;  // p2 is bubble from point up/right

        // check flexible size; area need to provide enough space
        if (aa[dim.h] < bs[dim.h]) return null;  // available area is smaller than bubble

        // if mode === 'y' we have flexibility to move vertically
        // if mode === 'x' we have flexibility to move horizontally
        // but in either case the opposite direction is locked
        // and therefore has to fit into the available area
        if (params.ignoreRange !== true &&
            !Help4.isRangeInRange({x: p1[dim.x], w: bs[dim.w]}, {x: aa[dim.x], w: aa[dim.w]}))
        {
            // bubble is outside available area in the not flexibly direction
            return null;
        }

        const aaMax = aa[dim.y] + aa[dim.h];  // would be bottom/right of available area, depending on mode

        let v1 = p1[dim.y];
        if (v1 < aa[dim.y]) {
            // position outside of area; bubble unable to move any more down
            return null;
        } else if (v1 + bs[dim.h] > aaMax) {
            // bubble outside of area; move it up into area if needed
            v1 = aaMax - bs[dim.h];

            // is as much down as possible, just moved into area
            // still does not fit -> bubble does not fit here at all
            if (v1 < aa[dim.y]) return null;
        }

        let v2 = p2[dim.y];
        if (v2 + bs[dim.h] > aaMax) {
            // bubble unable to move any more up
            return null;
        } else if (v2 < aa[dim.y]) {
            // move it down into area if needed
            v2 = aa[dim.y];

            // we know that the bubble will fit as this test has been done for v1 already
        }

        // two extreme position are found, one is as much up and one as much down
        // use their midpoint to get the best fit
        return (v1 + v2) >> 1;
    }

    // runs in scope of info (see _alignToPoints)
    function _alignScrollableUserDefined(connectionPoints) {
        // XRAY-1054: the 1st prio is a user defined orientation

        // 1st: try whether this one fits
        let r;
        const userPrio = this.priority[0];  // user prio is moved to front; see _setPriority
        if (userPrio === ALIGNMENT.left || userPrio === ALIGNMENT.right) {
            if (r = _getOriLR.call(this, userPrio, connectionPoints)) return r;
        } else {
            if ((r = _getOriTB.call(this, userPrio, connectionPoints)) && (r = _adjustOriTB.call(this, r))) return r;
        }

        // 2nd: use auto-alignment for best fit
        return _alignScrollableBestPossible.call(this, connectionPoints);
    }

    // runs in scope of info (see _alignToPoints)
    function _alignScrollableBestPossible(connectionPoints) {
        // in this part, prefer left/right over top/bottom as we need to scroll h it is better to have more h-space
        // and this will most likely be available left/right (but bubble needs to fit fully wrt width)

        // order of priorities for left, right
        const plr = this.priority.indexOf(ALIGNMENT.left) < this.priority.indexOf(ALIGNMENT.right)
            ? [ALIGNMENT.left, ALIGNMENT.right]
            : [ALIGNMENT.right, ALIGNMENT.left];

        // order of priorities for top, bottom
        const ptb = this.priority.indexOf(ALIGNMENT.top) < this.priority.indexOf(ALIGNMENT.bottom)
            ? [ALIGNMENT.top, ALIGNMENT.bottom]
            : [ALIGNMENT.bottom, ALIGNMENT.top];

        // 1st: check left/right and return if one fits
        let r;
        if (r = _getOriLR.call(this, plr[0], connectionPoints)) return r;
        if (r = _getOriLR.call(this, plr[1], connectionPoints)) return r;

        // 2nd: check both, top+bottom and use the one with more space
        let s = new Array(2);
        s[0] = _getOriTB.call(this, ptb[0], connectionPoints);
        s[1] = _getOriTB.call(this, ptb[1], connectionPoints);
        if (s[0] && s[1]) {
            s = s[0].space >= s[1].space ? s[0] : s[1];
        } else {
            s = s[0] || s[1] || {space: 0};
        }
        if (s.space > 0) return _adjustOriTB.call(this, s);

        return null;
    }

    // runs in scope of info (see _alignToPoints)
    function _getOriLR(priority, connectionPoints) {
        const ori = priority.charAt(0);
        const point = connectionPoints[ori];
        if (!point) return null;

        const x = _getBubblePosition.call(this, ori, point).x;
        if (Help4.isRangeInRange({x: x, w: this.bubbleSize.w}, this.availableArea)) {  // bubble w does fit into area
            const r = _getScrollableVertical.call(this, ori, point);
            if (r) {
                r.orientation = priority;
                r.x = x;
                r.point = point;
                return r;
            }
        }

        return null;
    }

    // runs in scope of info (see _alignToPoints)
    function _getOriTB(priority, connectionPoints) {
        const ori = priority.charAt(0);
        const point = connectionPoints[ori];
        if (!point) return null;

        const bs = this.bubbleSize;
        const as = this.arrowSize;
        const arrowSpace = as * ARROW_MULT;  // space to be reserved on bubble; not to look ugly
        const p1 = {x: point.x - arrowSpace};  // from pt to right
        const p2 = {x: point.x + arrowSpace - bs.w};  // from pt to left

        const x = _getBestFit.call(this, {mode: 'x', p1: p1, p2: p2, ignoreRange: true});
        if (x) {
            const aa = this.availableArea;
            const s = ori === 't'  // available space above/below connection point wrt arrowSize
                ? point.y - aa.y - as
                : aa.y + aa.h - point.y - as;
            if (s > MIN_SCROLL_HEIGHT) return {space: s, orientation: priority, x: x, point: point};
        }

        return null;
    }

    // runs in scope of info (see _alignToPoints)
    function _adjustOriTB(data) {
        const ori = data.orientation.charAt(0);
        const b = _adjustScrollableHeight.call(this, data.space, MIN_SCROLL_HEIGHT, ori);
        if (!b) return null;

        data.y = _getBubblePosition.call(this, ori, data.point, {h: b.h}).y;
        data.arrowX = data.point.x - data.x - this.arrowSize;
        return data;
    }

    // runs in scope of info (see _alignToPoints)
    function _getScrollableVertical(ori, connectionPoint) {
        const b = _adjustScrollableHeight.call(this, this.availableArea.h, MIN_SCROLL_HEIGHT, ori);
        if (!b) return null;

        // calculate arrow position
        const as = this.arrowSize;
        const arrowSpace = as * ARROW_MULT;  // space to be reserved on bubble; not to look ugly
        const ay = connectionPoint.y - b.y - as;
        if (ay < arrowSpace || ay > b.h - arrowSpace * 2) {
            // arrow would be outside bubble
            _resetScrollableHeight.call(this);
            return null;
        }

        return {y: b.y, arrowY: ay};
    }

    // runs in scope of info (see _alignToPoints)
    function _adjustScrollableHeight(availH, minH, ori) {
        let bh;  // effective height of bubble
        let rh = 0;  // height to further reduce the bubble
        let ch = null, ch1;  // content height and endless-loop cache

        // adjust availH to have the result look better:
        // - avoid scrollbar overlapping and
        // - ensure little free space between window and bubble
        const sh = Help4.Element.getScrollbarSize().h;  // scrollbar height
        const fs = 16;  // free space
        switch (ori) {
            case 'l':
            case 'r':
                availH -= sh + 2 * fs;  // one free space is added to y again; see below
                break;
            case 't':
                availH -= fs;
                break;
            case 'b':
                availH -= sh + fs;
                break;
            case 'c':
                availH -= sh + fs;
                break;
        }

        while (1) {  // as long as bubble does not fit in
            // new height of content; little space on top and above
            ch1 = availH - rh;
            if (ch1 <= 0) return null;  // does not fit

            // endless loop protection; sometimes always the same value is generated
            // protect against that by reducing the height again
            ch = ch === ch1 ? ch1 * 0.95 : ch1;

            this.contentDom.style.height = ch + 'px';  // set new content height

            // due to reduced content height, the bubble height has decreased
            bh = this.bubbleDom.offsetHeight;
            if (bh > availH) {  // too big
                rh += bh - availH;
                continue;  // try again
            } else if (bh < minH) {  // bubble would be too small
                _resetScrollableHeight.call(this);
                return null;  // does not fit
            }

            // return new bubble height and y that will center bubble in available space
            let y = (availH - bh) >> 1;
            if (ori === 'l' || ori === 'r') y += fs;
            if (ori === 'c') y += fs >> 1;
            return {y: Math.max(0, y), h: bh};
        }
    }

    // runs in scope of info (see _alignToPoints)
    function _resetScrollableHeight() {
        this.contentDom.style.height = '';  // reset
    }

    // runs in scope of info (see _alignToPoints)
    function _alignCentered() {
        const aa = this.availableArea;
        const bs = this.bubbleSize;
        const w = window.innerWidth;
        let h = window.innerHeight;

        const tests = [
            {o: 'center', w: aa.w, h: aa.h},  // center within area regardless of point
            {o: 'screen', w: w, h: h},  // center within window regardless of point and area
            {o: 'fit-h', w: w, h: h}  // decrease height to fit into window
        ];

        for (const c of tests) {
            if (bs.w > c.w) continue;

            let b, y;
            if (c.o === 'fit-h') {
                if (this.contentSize && (b = _adjustScrollableHeight.call(this, c.h, 0, 'c'))) {
                    y = b.y;
                    h = b.h;
                }
            } else if (bs.h <= c.h) {
                y = Math.max(0, (c.h - bs.h) >> 1);
                h = bs.h;
            }

            if (typeof y === 'number') {
                return {
                    orientation: c.o,
                    x: aa.x + Math.max(0, (c.w - bs.w) >> 1),
                    y: aa.y + y
                };
            }
        }

        return null;  // alignment impossible
    }
})();