* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import { on, off, isDisplayNone } from '../dom'
import { PopupManager } from '../popup-manager'
import { globalConfig, isServer } from '../globalConfig'
import { typeOf } from '../type'
const positions = ['left', 'right', 'top', 'bottom']
const modifiers = ['shift', 'offset', 'preventOverflow', 'keepTogether', 'arrow', 'flip', 'applyStyle']
const DEFAULTS = {
arrowOffset: 0,
arrowElement: '[x-arrow]',
boundariesElement: 'viewport',
boundariesPadding: 5,
flipBehavior: 'flip',
forceAbsolute: false,
gpuAcceleration: true,
offset: 0,
placement: 'bottom',
preventOverflowOrder: positions,
modifiers,
updateHiddenPopperOnScroll: false
}
const setStyle = (el: HTMLElement, styles: object) => {
const isNumeric = (n) => n !== '' && !isNaN(parseFloat(n)) && isFinite(n)
Object.keys(styles).forEach((prop) => {
let unit = ''
if (~['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) && isNumeric(styles[prop])) {
unit = 'px'
}
el.style[prop] = styles[prop] + unit
})
}
const getOffsetParent = (el: HTMLElement) => {
let offsetParent = el.offsetParent as HTMLElement
return offsetParent === window.document.body || !offsetParent ? window.document.documentElement : offsetParent
}
const getStyleComputedProperty = (el: HTMLElement, property: string) => {
if (!el || el.nodeType !== 1) {
return
}
let css = window.getComputedStyle(el, null)
return css[property]
}
const isFixed = (el: HTMLElement) => {
if (el === window.document.body) {
return false
}
if (getStyleComputedProperty(el, 'position') === 'fixed') {
return true
}
if (el.host) {
el = el.host
}
return el.parentNode ? isFixed(el.parentNode as HTMLElement) : false
}
const getBoundingClientRect = (el: HTMLElement) => {
let rect = el.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.right - rect.left,
height: rect.bottom - rect.top
}
}
const isScrollElement = (el: HTMLElement) => {
if (!el || el.nodeType !== 1) {
return false
}
const css = window.getComputedStyle(el, null)
const overflow = css.overflow
const overflowX = css.overflowX
const overflowY = css.overflowY
const pattern = /(auto|scroll|overlay|clip)/
const hasScrollableContent = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth
return hasScrollableContent && pattern.test(overflow + overflowY + overflowX)
}
const getAdjustOffset = (parent: HTMLElement) => {
const placeholder = document.createElement('div')
setStyle(placeholder, {
opacity: 0,
position: 'fixed',
width: 1,
height: 1,
top: 0,
left: 0,
'z-index': '-99'
})
parent.appendChild(placeholder)
const result = getBoundingClientRect(placeholder)
parent.removeChild(placeholder)
return result
}
export const getScrollParent: (el: HTMLElement) => HTMLElement = (el) => {
let parent = el.parentNode
if (!parent) {
return el
}
if (parent === window.document) {
if (window.document.body.scrollTop || window.document.body.scrollLeft) {
return window.document.body as HTMLElement
}
return window.document.documentElement
}
if (isScrollElement(parent as any)) {
return parent as HTMLElement
}
return getScrollParent(parent as any)
}
const getOffsetRectRelativeToCustomParent = (
el: HTMLElement,
parent: HTMLElement,
fixed: boolean,
popper: HTMLElement
) => {
let { top, left, width, height } = getBoundingClientRect(el)
if (fixed) {
if (popper.parentElement) {
const { top: adjustTop, left: adjustLeft } = getAdjustOffset(popper.parentElement)
top -= adjustTop
left -= adjustLeft
}
return {
top,
left,
bottom: top + height,
right: left + width,
width,
height
}
}
let parentRect = getBoundingClientRect(parent)
let rect = {
top: top - parentRect.top,
left: left - parentRect.left,
bottom: top - parentRect.top + height,
right: left - parentRect.left + width,
width,
height
}
return rect
}
const getScrollTopValue = (el: HTMLElement) =>
el === document.body ? Math.max(document.documentElement.scrollTop, document.body.scrollTop) : el.scrollTop || 0
const getScrollLeftValue = (el: HTMLElement) =>
el === document.body ? Math.max(document.documentElement.scrollLeft, document.body.scrollLeft) : el.scrollLeft || 0
const getMaxWH = (body, html) => {
const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight)
const width = Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth)
return { width, height }
}
const getOuterSizes = (el: HTMLElement) => {
let _display = el.style.display
let _visibility = el.style.visibility
el.style.display = 'block'
el.style.visibility = 'hidden'
let styles = window.getComputedStyle(el)
let x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom)
let y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight)
let result = { width: el.offsetWidth + y, height: el.offsetHeight + x }
el.style.display = _display
el.style.visibility = _visibility
return result
}
const getOppositePlacement = (placement: string) => {
let hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' }
return placement.replace(/left|right|bottom|top/g, (matched) => hash[matched])
}
const getPopperClientRect = (popperOffsets: PopperOffsets) => {
let offsets = { ...popperOffsets }
offsets.right = offsets.left + offsets.width
offsets.bottom = offsets.top + offsets.height
return offsets
}
const getAllScrollParents: (el: HTMLElement, parents?: HTMLElement[]) => HTMLElement[] = (
el: HTMLElement,
parents = [] as HTMLElement[]
) => {
const parent = el.parentNode as HTMLElement
if (parent) {
isScrollElement(parent) && parents.push(parent)
if (getStyleComputedProperty(parent, 'position') === 'fixed') {
return parents
}
return getAllScrollParents(parent, parents) as HTMLElement[]
}
return parents
}
const getOffsetRect = (el: HTMLElement) => {
const elementRect = {
width: el.offsetWidth,
height: el.offsetHeight,
left: el.offsetLeft,
top: el.offsetTop,
right: 0,
bottom: 0
}
elementRect.right = elementRect.left + elementRect.width
elementRect.bottom = elementRect.top + elementRect.height
return elementRect
}
const stopFn = (ev: Event) => {
ev.stopPropagation()
}
const resizeOb =
!isServer && typeof ResizeObserver === 'function'
? new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (entry.target.popperVm && entry.contentRect.height > 50) {
entry.target.popperVm.update()
}
})
})
: null
interface PopperOptions {
arrowOffset: number
arrowElement: string
boundariesElement: string | HTMLElement
boundariesPadding: number
flipBehavior: string
forceAbsolute: boolean
gpuAcceleration: boolean
offset: number
placement: string
preventOverflowOrder: string[]
modifiers: string[]
modifierFns: Function[]
removeOnDestroy?: boolean
bubbling?: boolean
adjustArrow?: boolean
}
interface PopperState {
position: 'absolute' | 'fixed'
updateCallback?: (data: UpdateData) => void
scrollTarget: HTMLElement | null
scrollTargets: HTMLElement[] | null
updateBoundFn: () => void
scrollUpdate: () => void
}
interface ReferenceOffsets {
top: number
left: number
bottom: number
right: number
width: number
height: number
}
export interface PopperOffsets {
position: 'absolute' | 'fixed'
top: number
left: number
bottom: number
right: number
width: number
height: number
}
interface arrowOffsets {
top: number
left: number
}
export interface UpdateData {
instance: Popper
styles: {}
placement: string
_originalPlacement: string
offsets: {
popper: PopperOffsets
reference: ReferenceOffsets
arrow?: arrowOffsets
}
arrowElement: HTMLElement
boundaries: {
right: number
left: number
top: number
bottom: number
}
flipped?: boolean
}
* 调用后就popper就'absolute' | 'fixed' 定位,并立即计算一次popper后的位置,并绑定scroll 和 resize事件!
*/
export class PopperJS {
_reference: HTMLElement
_popper: HTMLElement
state: PopperState
_options: PopperOptions
modifiers: Record<string, Function> = {}
popperOuterSize = null as unknown as { width: number; height: number }
constructor(reference: HTMLElement, popper: HTMLElement, options: PopperOptions) {
this._reference = reference
this._popper = popper
this.state = {} as PopperState
this._options = { ...DEFAULTS, ...options } as any
this._options.modifierFns = modifiers.map((modifier) => {
return this[modifier]
})
if (!isServer) {
this._popper.setAttribute('x-placement', this._options.placement)
this.state.position = this._getPopperPositionByRefernce(this._reference)
setStyle(this._popper, { position: this.state.position, top: 0 })
if (this._popper) {
this._popper.popperVm = this
resizeOb && resizeOb.observe(this._popper)
}
this.update()
this._setupEventListeners()
}
}
destroy() {
this._popper.removeAttribute('x-placement')
this._popper.style.display = 'none'
this._removeEventListeners()
this._options.removeOnDestroy && this._popper.remove()
return this
}
onUpdate(callback) {
this.state.updateCallback = callback
return this
}
update() {
let data = { instance: this, styles: {} } as unknown as UpdateData
this.stopEventBubble()
this.popperOuterSize = null as unknown as { width: number; height: number }
data.placement = data._originalPlacement = this._options.placement
data.offsets = this._getRefPopOffsets(this._popper, this._reference, data.placement)
data.boundaries = this._getBoundaries(data, this._options.boundariesPadding, this._options.boundariesElement)
data = this.runModifiers(data, this._options.modifierFns)
typeof this.state.updateCallback === 'function' && this.state.updateCallback(data)
}
stopEventBubble() {
if (!this._popper) return
if (!this._popper.onmousewheel) this._popper.onmousewheel = stopFn
if (!this._popper.onwheel) this._popper.onwheel = stopFn
}
runModifiers(data: UpdateData, modifiers: Function[], ends?: Function) {
let modifiersToRun = modifiers.slice()
const _options = this._options
if (ends !== undefined) {
modifiersToRun = this._options.modifierFns.slice(
0,
_options.modifierFns.findIndex((m) => m === ends)
)
}
modifiersToRun.forEach((modifier) => {
if (typeOf(modifier) === 'function') {
data = modifier.call(this, data)
}
})
return data
}
applyStyle(data: UpdateData) {
let styles: any = { position: data.offsets.popper.position }
let left = Math.round(data.offsets.popper.left)
let top = Math.round(data.offsets.popper.top)
if (this._options.gpuAcceleration) {
styles.transform = `translate3d(${left}px, ${top}px, 0)`
Object.assign(styles, { top: 0, left: 0 })
} else {
Object.assign(styles, { top, left })
}
Object.assign(styles, data.styles)
setStyle(this._popper, styles)
this._popper.setAttribute('x-placement', data.placement)
if (data.offsets.arrow) {
setStyle(data.arrowElement, data.offsets.arrow)
}
return data
}
shift(data: UpdateData) {
let placement = data.placement
let basePlacement = placement.split('-')[0]
let shiftVariation = placement.split('-')[1]
if (shiftVariation) {
let { top, left, height, width } = data.offsets.reference
let popper = getPopperClientRect(data.offsets.popper)
let shiftOffsets = {
y: {
start: { top },
end: { top: top + height - popper.height }
},
x: {
start: { left },
end: { left: left + width - popper.width }
}
}
let axis = ~['bottom', 'top'].indexOf(basePlacement) ? 'x' : 'y'
data.offsets.popper = Object.assign(popper, shiftOffsets[axis][shiftVariation])
}
return data
}
preventOverflow(data: UpdateData) {
if (this._options.ignoreBoundaries) {
return data
}
let order = this._options.preventOverflowOrder
let popper = getPopperClientRect(data.offsets.popper)
let check = {
top: () => {
let { top } = popper
if (top < data.boundaries.top) {
top = Math.max(top, data.boundaries.top)
}
return { top }
},
right: () => {
let { left } = popper
if (popper.right > data.boundaries.right) {
left = Math.min(left, data.boundaries.right - popper.width)
}
return { left }
},
bottom: () => {
let { top } = popper
if (popper.bottom > data.boundaries.bottom) {
top = Math.min(top, data.boundaries.bottom - popper.height)
}
return { top }
},
left: () => {
let { left } = popper
if (popper.left < data.boundaries.left) {
left = Math.max(left, data.boundaries.left)
}
return { left }
}
}
order.forEach((direction) => {
data.offsets.popper = Object.assign(popper, check[direction]())
})
return data
}
keepTogether(data: UpdateData) {
let popper = getPopperClientRect(data.offsets.popper)
let reference = data.offsets.reference
if (popper.right < Math.floor(reference.left)) {
data.offsets.popper.left = Math.floor(reference.left) - popper.width
}
if (popper.left > Math.floor(reference.right)) {
data.offsets.popper.left = Math.floor(reference.right)
}
if (popper.bottom < Math.floor(reference.top)) {
data.offsets.popper.top = Math.floor(reference.top) - popper.height
}
if (popper.top > Math.floor(reference.bottom)) {
data.offsets.popper.top = Math.floor(reference.bottom)
}
return data
}
flip(data: UpdateData) {
if (data.flipped && data.placement === data._originalPlacement) {
return data
}
const placements = data.placement.split('-')
let placement = placements[0]
let placementOpposite = getOppositePlacement(placement)
let variation = placements[1] || ''
let flipOrderArr = [placement, placementOpposite]
flipOrderArr.forEach((step, index) => {
if (placement !== step || flipOrderArr.length === index + 1) {
return
}
placement = data.placement.split('-')[0]
placementOpposite = getOppositePlacement(placement)
let popperOffsets = getPopperClientRect(data.offsets.popper)
let isRightOrBottom = ['right', 'bottom'].includes(placement)
let refPosition = Math.floor(data.offsets.reference[placement])
let popupPosition = Math.floor(popperOffsets[placementOpposite])
if ((isRightOrBottom && refPosition > popupPosition) || (!isRightOrBottom && refPosition < popupPosition)) {
data.flipped = true
data.placement = flipOrderArr[index + 1]
if (variation) {
data.placement += `-${variation}`
}
data.offsets.popper = this._getRefPopOffsets(this._popper, this._reference, data.placement).popper
data = this.runModifiers(data, this._options.modifierFns, this.flip)
}
})
return data
}
offset(data: UpdateData) {
let offset = this._options.offset
let popper = data.offsets.popper
if (~data.placement.indexOf('left')) {
popper.top -= offset
} else if (~data.placement.indexOf('right')) {
popper.top += offset
} else if (~data.placement.indexOf('top')) {
popper.left -= offset
} else if (~data.placement.indexOf('bottom')) {
popper.left += offset
}
return data
}
arrow(data: UpdateData) {
let arrow: string | HTMLElement = this._options.arrowElement
let arrowOffset = this._options.arrowOffset
if (typeof arrow === 'string') {
arrow = this._popper.querySelector(arrow) as HTMLElement
}
if (!arrow || !this._popper.contains(arrow)) {
return data
}
let arrowStyle = {} as arrowOffsets
let placement = data.placement.split('-')[0]
let popper = getPopperClientRect(data.offsets.popper)
let reference = data.offsets.reference
let isVertical = ~['left', 'right'].indexOf(placement)
let calcProp = isVertical ? 'height' : 'width'
let opSide = isVertical ? 'bottom' : 'right'
let altSide = isVertical ? 'left' : 'top'
let side = isVertical ? 'top' : 'left'
let popperRect = this.popperOuterSize ? this.popperOuterSize : (this.popperOuterSize = getOuterSizes(this._popper))
let arrowRect = getOuterSizes(arrow)
let arrowSize = arrowRect[calcProp]
if (reference[opSide] - arrowSize < popper[side]) {
data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowSize)
}
if (reference[side] + arrowSize > popper[opSide]) {
data.offsets.popper[side] += reference[side] + arrowSize - popper[opSide]
}
let center = reference[side] + (arrowOffset || reference[calcProp] / 2 - arrowSize / 2)
let sideValue = center - popper[side]
const safeLeft = 4
sideValue = Math.max(Math.min(popper[calcProp] - arrowSize - safeLeft, sideValue), safeLeft)
arrowStyle[side] = sideValue
arrowStyle[altSide] = ''
const params = this._options.placement.split('-')
if (this._options.adjustArrow && ~['top', 'bottom'].indexOf(params[0]) && side === 'left') {
if (params[1] === 'start') {
arrowStyle.left = safeLeft
} else if (!params[1]) {
arrowStyle.left = (popperRect.width - arrowRect.width) / 2
}
}
data.offsets.arrow = arrowStyle
data.arrowElement = arrow
return data
}
_getPopperPositionByRefernce(reference: HTMLElement) {
if (this._options.forceAbsolute) {
return 'absolute'
}
let isParentFixed = isFixed(reference)
return isParentFixed ? 'fixed' : 'absolute'
}
_getRefPopOffsets(popper, reference, placement) {
placement = placement.split('-')[0]
let popperOffsets = { position: this.state.position } as PopperOffsets
let isParentFixed = popperOffsets.position === 'fixed'
let referenceOffsets = getOffsetRectRelativeToCustomParent(
reference,
getOffsetParent(popper),
isParentFixed,
popper
)
const { width, height } = this.popperOuterSize
? this.popperOuterSize
: (this.popperOuterSize = getOuterSizes(popper))
if (~['right', 'left'].indexOf(placement)) {
popperOffsets.top = referenceOffsets.top + referenceOffsets.height / 2 - height / 2
if (placement === 'left') {
popperOffsets.left = referenceOffsets.left - width
} else {
popperOffsets.left = referenceOffsets.right
}
} else {
popperOffsets.left = referenceOffsets.left + referenceOffsets.width / 2 - width / 2
if (placement === 'top') {
popperOffsets.top = referenceOffsets.top - height
} else {
popperOffsets.top = referenceOffsets.bottom
}
}
popperOffsets.width = width
popperOffsets.height = height
return {
popper: popperOffsets,
reference: referenceOffsets
}
}
_setupEventListeners() {
this.state.updateBoundFn = this.update.bind(this)
this.state.scrollUpdate = () => {
if (this._options.updateHiddenPopperOnScroll) {
this.state.updateBoundFn()
} else {
if (isDisplayNone(this._reference)) return
this.state.updateBoundFn()
}
}
on(window, 'resize', this.state.updateBoundFn)
if (this._options.boundariesElement !== 'window') {
let target: HTMLElement = this._options.scrollParent || getScrollParent(this._reference)
const customTargets = []
if (target?.dataset?.tag?.includes('-form')) {
customTargets.push(target)
let realTarget = getScrollParent(target)
if (realTarget === window.document.body || realTarget === window.document.documentElement) {
realTarget = window as any
}
customTargets.push(realTarget)
}
if (target === window.document.body || target === window.document.documentElement) {
target = window as any
}
this.state.scrollTarget = target
if (this._options.bubbling || PopupManager.globalScroll) {
let targets = getAllScrollParents(this._reference)
this.state.scrollTargets = targets || []
targets.forEach((target) => {
on(target, 'scroll', this.state.scrollUpdate)
})
} else {
if (customTargets.length) {
this.state.scrollTargets = customTargets
customTargets.forEach((target) => {
on(target, 'scroll', this.state.scrollUpdate)
})
} else {
on(target, 'scroll', this.state.scrollUpdate)
}
}
}
}
_removeEventListeners() {
off(window, 'resize', this.state.updateBoundFn)
if (this._options.boundariesElement !== 'window' && this.state.scrollTarget) {
off(this.state.scrollTarget, 'scroll', this.state.scrollUpdate)
this.state.scrollTarget = null
if (this._options.bubbling || PopupManager.globalScroll) {
let targets = this.state.scrollTargets || []
targets.forEach((target) => {
off(target, 'scroll', this.state.scrollUpdate)
})
this.state.scrollTargets = null
}
}
this.state.updateBoundFn = null as any
this.state.scrollUpdate = null as any
}
_getBoundaries(data: UpdateData, padding: number, boundariesElement: string | HTMLElement) {
let boundaries = { right: 0, left: 0, top: 0, bottom: 0 }
if (boundariesElement === 'window' || boundariesElement === 'body') {
let body = window.document.body
let html = window.document.documentElement
let { width, height } = getMaxWH(body, html)
boundaries = { top: 0, right: width, bottom: height, left: 0 }
} else if (boundariesElement === 'viewport') {
let offsetParent = getOffsetParent(this._popper)
let scrollParent = getScrollParent(this._popper)
let offsetParentRect = getOffsetRect(offsetParent)
let isFixed = data.offsets.popper.position === 'fixed'
const noScroll = isFixed || (!this._options.appendToBody && ['right', 'left'].includes(this._options.placement))
let scrollTop = noScroll ? 0 : getScrollTopValue(scrollParent)
let scrollLeft = noScroll ? 0 : getScrollLeftValue(scrollParent)
const viewportWindow = globalConfig.viewportWindow || PopupManager.viewportWindow || window
boundaries = {
top: 0 - (offsetParentRect.top - scrollTop),
right: viewportWindow.document.documentElement.clientWidth - (offsetParentRect.left - scrollLeft),
bottom: viewportWindow.document.documentElement.clientHeight - (offsetParentRect.top - scrollTop),
left: 0 - (offsetParentRect.left - scrollLeft)
}
} else {
if (getOffsetParent(this._popper) === boundariesElement) {
const { clientWidth, clientHeight } = boundariesElement
boundaries = {
right: clientWidth,
bottom: clientHeight,
top: 0,
left: 0
}
} else {
boundaries = getOffsetRect(boundariesElement as HTMLElement)
}
}
boundaries.right -= padding
boundaries.left += padding
boundaries.bottom = boundaries.bottom - padding
boundaries.top = boundaries.top + padding
return boundaries
}
setOptions(options: PopperOptions) {
Object.assign(this._options, options)
}
}