Spaces:
Paused
Paused
const hasOwn = function(obj, key) { | |
return Object.prototype.hasOwnProperty.call(obj, key); | |
}; | |
const isNum = function(num) { | |
if (typeof num !== 'number' || isNaN(num)) { | |
return false; | |
} | |
const isInvalid = function(n) { | |
if (n === Number.MAX_VALUE || n === Number.MIN_VALUE || n === Number.NEGATIVE_INFINITY || n === Number.POSITIVE_INFINITY) { | |
return true; | |
} | |
return false; | |
}; | |
if (isInvalid(num)) { | |
return false; | |
} | |
return true; | |
}; | |
const toNum = (num) => { | |
if (typeof (num) !== 'number') { | |
num = parseFloat(num); | |
} | |
if (isNaN(num)) { | |
num = 0; | |
} | |
num = Math.round(num); | |
return num; | |
}; | |
const clamp = function(value, min, max) { | |
return Math.max(min, Math.min(max, value)); | |
}; | |
const isWindow = (obj) => { | |
return Boolean(obj && obj === obj.window); | |
}; | |
const isDocument = (obj) => { | |
return Boolean(obj && obj.nodeType === 9); | |
}; | |
const isElement = (obj) => { | |
return Boolean(obj && obj.nodeType === 1); | |
}; | |
// =========================================================================================== | |
export const toRect = (obj) => { | |
if (obj) { | |
return { | |
left: toNum(obj.left || obj.x), | |
top: toNum(obj.top || obj.y), | |
width: toNum(obj.width), | |
height: toNum(obj.height) | |
}; | |
} | |
return { | |
left: 0, | |
top: 0, | |
width: 0, | |
height: 0 | |
}; | |
}; | |
export const getElement = (selector) => { | |
if (typeof selector === 'string' && selector) { | |
if (selector.startsWith('#')) { | |
return document.getElementById(selector.slice(1)); | |
} | |
return document.querySelector(selector); | |
} | |
if (isDocument(selector)) { | |
return selector.body; | |
} | |
if (isElement(selector)) { | |
return selector; | |
} | |
}; | |
export const getRect = (target, fixed) => { | |
if (!target) { | |
return toRect(); | |
} | |
if (isWindow(target)) { | |
return { | |
left: 0, | |
top: 0, | |
width: window.innerWidth, | |
height: window.innerHeight | |
}; | |
} | |
const elem = getElement(target); | |
if (!elem) { | |
return toRect(target); | |
} | |
const br = elem.getBoundingClientRect(); | |
const rect = toRect(br); | |
// fix offset | |
if (!fixed) { | |
rect.left += window.scrollX; | |
rect.top += window.scrollY; | |
} | |
rect.width = elem.offsetWidth; | |
rect.height = elem.offsetHeight; | |
return rect; | |
}; | |
// =========================================================================================== | |
const calculators = { | |
bottom: (info, containerRect, targetRect) => { | |
info.space = containerRect.top + containerRect.height - targetRect.top - targetRect.height - info.height; | |
info.top = targetRect.top + targetRect.height; | |
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5); | |
}, | |
top: (info, containerRect, targetRect) => { | |
info.space = targetRect.top - info.height - containerRect.top; | |
info.top = targetRect.top - info.height; | |
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5); | |
}, | |
right: (info, containerRect, targetRect) => { | |
info.space = containerRect.left + containerRect.width - targetRect.left - targetRect.width - info.width; | |
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5); | |
info.left = targetRect.left + targetRect.width; | |
}, | |
left: (info, containerRect, targetRect) => { | |
info.space = targetRect.left - info.width - containerRect.left; | |
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5); | |
info.left = targetRect.left - info.width; | |
} | |
}; | |
// with order | |
export const getDefaultPositions = () => { | |
return Object.keys(calculators); | |
}; | |
const calculateSpace = (info, containerRect, targetRect) => { | |
const calculator = calculators[info.position]; | |
calculator(info, containerRect, targetRect); | |
if (info.space >= 0) { | |
info.passed += 1; | |
} | |
}; | |
// =========================================================================================== | |
const calculateAlignOffset = (info, containerRect, targetRect, alignType, sizeType) => { | |
const popoverStart = info[alignType]; | |
const popoverSize = info[sizeType]; | |
const containerStart = containerRect[alignType]; | |
const containerSize = containerRect[sizeType]; | |
const targetStart = targetRect[alignType]; | |
const targetSize = targetRect[sizeType]; | |
const targetCenter = targetStart + targetSize * 0.5; | |
// size overflow | |
if (popoverSize > containerSize) { | |
const overflow = (popoverSize - containerSize) * 0.5; | |
info[alignType] = containerStart - overflow; | |
info.offset = targetCenter - containerStart + overflow; | |
return; | |
} | |
const space1 = popoverStart - containerStart; | |
const space2 = (containerStart + containerSize) - (popoverStart + popoverSize); | |
// both side passed, default to center | |
if (space1 >= 0 && space2 >= 0) { | |
if (info.passed) { | |
info.passed += 2; | |
} | |
info.offset = popoverSize * 0.5; | |
return; | |
} | |
// one side passed | |
if (info.passed) { | |
info.passed += 1; | |
} | |
if (space1 < 0) { | |
const min = containerStart; | |
info[alignType] = min; | |
info.offset = targetCenter - min; | |
return; | |
} | |
// space2 < 0 | |
const max = containerStart + containerSize - popoverSize; | |
info[alignType] = max; | |
info.offset = targetCenter - max; | |
}; | |
const calculateHV = (info, containerRect) => { | |
if (['top', 'bottom'].includes(info.position)) { | |
info.top = clamp(info.top, containerRect.top, containerRect.top + containerRect.height - info.height); | |
return ['left', 'width']; | |
} | |
info.left = clamp(info.left, containerRect.left, containerRect.left + containerRect.width - info.width); | |
return ['top', 'height']; | |
}; | |
const calculateOffset = (info, containerRect, targetRect) => { | |
const [alignType, sizeType] = calculateHV(info, containerRect); | |
calculateAlignOffset(info, containerRect, targetRect, alignType, sizeType); | |
info.offset = clamp(info.offset, 0, info[sizeType]); | |
}; | |
// =========================================================================================== | |
const calculateDistance = (info, previousPositionInfo) => { | |
if (!previousPositionInfo) { | |
return; | |
} | |
// no change if position no change with previous | |
if (info.position === previousPositionInfo.position) { | |
return; | |
} | |
const ax = info.left + info.width * 0.5; | |
const ay = info.top + info.height * 0.5; | |
const bx = previousPositionInfo.left + previousPositionInfo.width * 0.5; | |
const by = previousPositionInfo.top + previousPositionInfo.height * 0.5; | |
const dx = Math.abs(ax - bx); | |
const dy = Math.abs(ay - by); | |
info.distance = Math.round(Math.sqrt(dx * dx + dy * dy)); | |
}; | |
// =========================================================================================== | |
const calculatePositionInfo = (info, containerRect, targetRect, previousPositionInfo) => { | |
calculateSpace(info, containerRect, targetRect); | |
calculateOffset(info, containerRect, targetRect); | |
calculateDistance(info, previousPositionInfo); | |
}; | |
// =========================================================================================== | |
const calculateBestPosition = (containerRect, targetRect, infoMap, withOrder, previousPositionInfo) => { | |
// position space: +1 | |
// align space: | |
// two side passed: +2 | |
// one side passed: +1 | |
const safePassed = 3; | |
if (previousPositionInfo) { | |
const prevInfo = infoMap[previousPositionInfo.position]; | |
if (prevInfo) { | |
calculatePositionInfo(prevInfo, containerRect, targetRect); | |
if (prevInfo.passed >= safePassed) { | |
return prevInfo; | |
} | |
prevInfo.calculated = true; | |
} | |
} | |
const positionList = []; | |
Object.values(infoMap).forEach((info) => { | |
if (!info.calculated) { | |
calculatePositionInfo(info, containerRect, targetRect, previousPositionInfo); | |
} | |
positionList.push(info); | |
}); | |
positionList.sort((a, b) => { | |
if (a.passed !== b.passed) { | |
return b.passed - a.passed; | |
} | |
if (withOrder && a.passed >= safePassed && b.passed >= safePassed) { | |
return a.index - b.index; | |
} | |
if (a.space !== b.space) { | |
return b.space - a.space; | |
} | |
return a.index - b.index; | |
}); | |
// logTable(positionList); | |
return positionList[0]; | |
}; | |
// const logTable = (() => { | |
// let time_id; | |
// return (info) => { | |
// clearTimeout(time_id); | |
// time_id = setTimeout(() => { | |
// console.table(info); | |
// }, 10); | |
// }; | |
// })(); | |
// =========================================================================================== | |
const getAllowPositions = (positions, defaultAllowPositions) => { | |
if (!positions) { | |
return; | |
} | |
if (Array.isArray(positions)) { | |
positions = positions.join(','); | |
} | |
positions = String(positions).split(',').map((it) => it.trim().toLowerCase()).filter((it) => it); | |
positions = positions.filter((it) => defaultAllowPositions.includes(it)); | |
if (!positions.length) { | |
return; | |
} | |
return positions; | |
}; | |
const isPositionChanged = (info, previousPositionInfo) => { | |
if (!previousPositionInfo) { | |
return true; | |
} | |
if (info.left !== previousPositionInfo.left) { | |
return true; | |
} | |
if (info.top !== previousPositionInfo.top) { | |
return true; | |
} | |
return false; | |
}; | |
// =========================================================================================== | |
// const log = (name, time) => { | |
// if (time > 0.1) { | |
// console.log(name, time); | |
// } | |
// }; | |
export const getBestPosition = (containerRect, targetRect, popoverRect, positions, previousPositionInfo) => { | |
const defaultAllowPositions = getDefaultPositions(); | |
let withOrder = true; | |
let allowPositions = getAllowPositions(positions, defaultAllowPositions); | |
if (!allowPositions) { | |
allowPositions = defaultAllowPositions; | |
withOrder = false; | |
} | |
// console.log('withOrder', withOrder); | |
// const start_time = performance.now(); | |
const infoMap = {}; | |
allowPositions.forEach((k, i) => { | |
infoMap[k] = { | |
position: k, | |
index: i, | |
top: 0, | |
left: 0, | |
width: popoverRect.width, | |
height: popoverRect.height, | |
space: 0, | |
offset: 0, | |
passed: 0, | |
distance: 0 | |
}; | |
}); | |
// log('infoMap', performance.now() - start_time); | |
const bestPosition = calculateBestPosition(containerRect, targetRect, infoMap, withOrder, previousPositionInfo); | |
// check left/top | |
bestPosition.changed = isPositionChanged(bestPosition, previousPositionInfo); | |
return bestPosition; | |
}; | |
// =========================================================================================== | |
const getTemplatePath = (width, height, arrowOffset, arrowSize, borderRadius) => { | |
const p = (px, py) => { | |
return [px, py].join(','); | |
}; | |
const px = function(num, alignEnd) { | |
const floor = Math.floor(num); | |
let n = num < floor + 0.5 ? floor + 0.5 : floor + 1.5; | |
if (alignEnd) { | |
n -= 1; | |
} | |
return n; | |
}; | |
const pxe = function(num) { | |
return px(num, true); | |
}; | |
const ls = []; | |
const innerLeft = px(arrowSize); | |
const innerRight = pxe(width - arrowSize); | |
arrowOffset = clamp(arrowOffset, innerLeft, innerRight); | |
const innerTop = px(arrowSize); | |
const innerBottom = pxe(height - arrowSize); | |
const startPoint = p(innerLeft, innerTop + borderRadius); | |
const arrowPoint = p(arrowOffset, 1); | |
const LT = p(innerLeft, innerTop); | |
const RT = p(innerRight, innerTop); | |
const AOT = p(arrowOffset - arrowSize, innerTop); | |
const RRT = p(innerRight - borderRadius, innerTop); | |
ls.push(`M${startPoint}`); | |
ls.push(`V${innerBottom - borderRadius}`); | |
ls.push(`Q${p(innerLeft, innerBottom)} ${p(innerLeft + borderRadius, innerBottom)}`); | |
ls.push(`H${innerRight - borderRadius}`); | |
ls.push(`Q${p(innerRight, innerBottom)} ${p(innerRight, innerBottom - borderRadius)}`); | |
ls.push(`V${innerTop + borderRadius}`); | |
if (arrowOffset < innerLeft + arrowSize + borderRadius) { | |
ls.push(`Q${RT} ${RRT}`); | |
ls.push(`H${arrowOffset + arrowSize}`); | |
ls.push(`L${arrowPoint}`); | |
if (arrowOffset < innerLeft + arrowSize) { | |
ls.push(`L${LT}`); | |
ls.push(`L${startPoint}`); | |
} else { | |
ls.push(`L${AOT}`); | |
ls.push(`Q${LT} ${startPoint}`); | |
} | |
} else if (arrowOffset > innerRight - arrowSize - borderRadius) { | |
if (arrowOffset > innerRight - arrowSize) { | |
ls.push(`L${RT}`); | |
} else { | |
ls.push(`Q${RT} ${p(arrowOffset + arrowSize, innerTop)}`); | |
} | |
ls.push(`L${arrowPoint}`); | |
ls.push(`L${AOT}`); | |
ls.push(`H${innerLeft + borderRadius}`); | |
ls.push(`Q${LT} ${startPoint}`); | |
} else { | |
ls.push(`Q${RT} ${RRT}`); | |
ls.push(`H${arrowOffset + arrowSize}`); | |
ls.push(`L${arrowPoint}`); | |
ls.push(`L${AOT}`); | |
ls.push(`H${innerLeft + borderRadius}`); | |
ls.push(`Q${LT} ${startPoint}`); | |
} | |
return ls.join(''); | |
}; | |
const getPathData = function(position, width, height, arrowOffset, arrowSize, borderRadius) { | |
const handlers = { | |
bottom: () => { | |
const d = getTemplatePath(width, height, arrowOffset, arrowSize, borderRadius); | |
return { | |
d, | |
transform: '' | |
}; | |
}, | |
top: () => { | |
const d = getTemplatePath(width, height, width - arrowOffset, arrowSize, borderRadius); | |
return { | |
d, | |
transform: `rotate(180,${width * 0.5},${height * 0.5})` | |
}; | |
}, | |
left: () => { | |
const d = getTemplatePath(height, width, arrowOffset, arrowSize, borderRadius); | |
const x = (width - height) * 0.5; | |
const y = (height - width) * 0.5; | |
return { | |
d, | |
transform: `translate(${x} ${y}) rotate(90,${height * 0.5},${width * 0.5})` | |
}; | |
}, | |
right: () => { | |
const d = getTemplatePath(height, width, height - arrowOffset, arrowSize, borderRadius); | |
const x = (width - height) * 0.5; | |
const y = (height - width) * 0.5; | |
return { | |
d, | |
transform: `translate(${x} ${y}) rotate(-90,${height * 0.5},${width * 0.5})` | |
}; | |
} | |
}; | |
return handlers[position](); | |
}; | |
// =========================================================================================== | |
// position style cache | |
const styleCache = { | |
// position: '', | |
// top: {}, | |
// bottom: {}, | |
// left: {}, | |
// right: {} | |
}; | |
export const getPositionStyle = (info, options = {}) => { | |
const o = { | |
bgColor: '#fff', | |
borderColor: '#ccc', | |
borderRadius: 5, | |
arrowSize: 10 | |
}; | |
Object.keys(o).forEach((k) => { | |
if (hasOwn(options, k)) { | |
const d = o[k]; | |
const v = options[k]; | |
if (typeof d === 'string') { | |
// string | |
if (typeof v === 'string' && v) { | |
o[k] = v; | |
} | |
} else { | |
// number | |
if (isNum(v) && v >= 0) { | |
o[k] = v; | |
} | |
} | |
} | |
}); | |
const key = [ | |
info.width, | |
info.height, | |
info.offset, | |
o.arrowSize, | |
o.borderRadius, | |
o.bgColor, | |
o.borderColor | |
].join('-'); | |
const positionCache = styleCache[info.position]; | |
if (positionCache && key === positionCache.key) { | |
const st = positionCache.style; | |
st.changed = styleCache.position !== info.position; | |
styleCache.position = info.position; | |
return st; | |
} | |
// console.log(options); | |
const data = getPathData(info.position, info.width, info.height, info.offset, o.arrowSize, o.borderRadius); | |
// console.log(data); | |
const viewBox = [0, 0, info.width, info.height].join(' '); | |
const svg = [ | |
`<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">`, | |
`<path d="${data.d}" fill="${o.bgColor}" stroke="${o.borderColor}" transform="${data.transform}" />`, | |
'</svg>' | |
].join(''); | |
// console.log(svg); | |
const backgroundImage = `url("data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}")`; | |
const background = `${backgroundImage} center no-repeat`; | |
const padding = `${o.arrowSize + o.borderRadius}px`; | |
const style = { | |
background, | |
backgroundImage, | |
padding, | |
changed: true | |
}; | |
styleCache.position = info.position; | |
styleCache[info.position] = { | |
key, | |
style | |
}; | |
return style; | |
}; | |