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