import { Component, ContextType, Ref, MouseEventHandler, CSSProperties, createRef, PropsWithChildren, ReactNode, isValidElement, } from 'react' // import * as PropTypes from 'prop-types' import c from 'classnames' import { createPopper, Instance as PopperInstance, Options as PopperOptions, Placement, Rect as PopperRect, ModifierArguments, VirtualElement, } from '@popperjs/core' import {Popup, PopupProps} from '@befe/brick-comp-popup' import { deprecatedProp, ifNodeContains, pick, refNode, safeInvoke, } from '@befe/brick-utils' import {isEqual, isFunction, isUndefined, isNumber} from 'lodash-es' import {ChildNodePopperGetter, PopperContext} from './popper-context' export type PopperTargetObject = Element | VirtualElement export type PopperTarget = PopperTargetObject | (() => PopperTargetObject | void | null) | null type PopperInstanceState = PopperInstance['state'] type ModifierData = PopperInstanceState['modifiersData'] type PropsFromPopup = Omit export interface PopperProps extends PropsFromPopup { /** * 自定义 class */ className?: string /** * 是否显示 */ visible?: boolean /** * 目标元素 * * @type Element | VirtualElement | (() => Element | VirtualElement | void | null) */ target?: PopperTarget /** * 浮层相对于 target 的位置 * | 'top-start' | 'top' | 'top-end' * | 'right-start' | 'right' | 'right-end' * | 'bottom-end' | 'bottom' | 'bottom-start' * | 'left-end' | 'left' | 'left-start' */ placement?: Placement /** * 位置策略 * - 默认为 `'absolute'` 可以满足绝大多数场景, * - 如若果 target element 位于一个 fixed 容器,设置为 `'fixed'` */ strategy?: PopperOptions['strategy'] /** * use `strategy` instead * @deprecated since 0.2.44 */ positionFixed?: boolean /** * modifier 设置 */ modifiers?: PopperOptions['modifiers'] /** * popperWrap 的 element ref 函数 */ refPopperWrap?: Ref /** * popperWrap 最小宽度匹配到 target 的宽度 */ matchMinWidthToTarget?: boolean /** * popperWrap 宽度匹配到 target 的宽度 */ matchWidthToTarget?: boolean /** * 鼠标进入 popper wrap 回调 */ onMouseEnter?: MouseEventHandler /** * 鼠标离开 popper wrap 回调 */ onMouseLeave?: MouseEventHandler /** * popper 总状态更新时的回调(预留) */ onChange?: (popperState: PopperState) => void /** * 是否带箭头 */ withArrow?: boolean /** * 箭头垂直于 placement 的偏移,只在 placement 为 \*-end, \*-start 时有效 * - `'auto'`: 当 popper 宽/高尺寸小于 target 时按默认值偏移 * - `number`: 按指定量偏移 */ arrowOffset?: 'auto' | number } interface PopperState { placement: Placement popperStyles: CSSProperties arrowStyles: CSSProperties modifiersData: ModifierData popperRect: PopperRect targetRect: PopperRect isHidden?: boolean } function getTargetObject(target?: PopperTarget) { if (isFunction(target)) { return target() } return target } function normalizeNaNProperties(style: CSSProperties) { const normalizedStyle: Record = {} Object.keys(style).forEach(key => { const k = key as keyof CSSProperties const value = style[k] normalizedStyle[k] = typeof value === 'string' || !isNaN(value as number) ? value : '' }) return normalizedStyle as CSSProperties } function getNumberCssProp(styles: CSSProperties, propName: keyof CSSProperties) { const value = styles[propName] if (isNumber(value)) { return value } if (isUndefined(value)) { return NaN } return Math.round(parseFloat(value)) || NaN } const ARROW_INSET_NAME: Record = { 'top-start': 'left', 'bottom-start': 'left', 'top-end': 'right', 'bottom-end': 'right', 'left-start': 'top', 'right-start': 'top', 'left-end': 'bottom', 'right-end': 'bottom', } // arrow 设计宽度 // const ARROW_SIZE = 8 // 8 // const ARROW_BOX_SIZE = Math.round(Math.sqrt(Math.pow(ARROW_SIZE, 2) / 2)) // 6 同 popper.$popper-arrow-box-size // 小尺寸 target 情景下 arrow 中心的固定偏移量 (设计值) const DEFAULT_ARROW_OFFSET_HORIZONTAL = 20 const DEFAULT_ARROW_OFFSET_VERTICAL = 12 // 小尺寸 target 的 临界宽高,如果 target 元素的宽高小于这个临界值,就切换为固定偏移量的对齐方式 const TERMINATE_WIDTH = DEFAULT_ARROW_OFFSET_HORIZONTAL * 2 const TERMINATE_HEIGHT = DEFAULT_ARROW_OFFSET_VERTICAL * 2 const initialPopperStyles: CSSProperties = { position: 'absolute', opacity: 0, pointerEvents: 'none', } const initialArrowStyles: CSSProperties = {} const initialPopperRect = { x: 0, y: 0, width: 0, height: 0, } const initialTargetRect = { x: 0, y: 0, width: 0, height: 0, } // type PopperPropsWithDefaults = PopperProps & Required> const DOM_RECT_KEYS = ['height', 'width', 'top', 'left'] as const // type RectKey = (typeof DOM_RECT_KEYS)[number] // 判断 target / popper 尺寸是否有变化 // 用 "差异小于某个很小的百分比" 来判断, // 因为存在 "浏览器可能在前后两次绘制存在细微差别" 的差别(理论上应该一致的), 比如 chrome 88 缩小为 80% 情况下 // 细微差别容差 RECT_ALLOWANCE 没有坚实根据,取的经验值 const RECT_ALLOWANCE = 0.01 const isRectChanged = (rectLeft: DOMRect | null | undefined, rectRight: DOMRect | null | undefined) => { if (rectLeft === rectRight || !rectLeft || !rectRight) { return false } return DOM_RECT_KEYS.some(key => { const diff = Math.abs(rectLeft[key] - rectRight[key]) / ((rectLeft[key] + rectRight[key]) / 2) return diff > RECT_ALLOWANCE }) } export class Popper extends Component { static displayName = 'Popper' static propTypes = { positionFixed: deprecatedProp('^0.2.42', 'use `strategy` instead'), } static defaultProps = { className: '', destroyOnHide: true, placement: 'bottom', strategy: 'absolute', matchMinWidthToTarget: false, withArrow: false, arrowOffset: 'auto', } static contextType = PopperContext context!: ContextType state: PopperState = { placement: Popper.defaultProps.placement as Placement, popperStyles: initialPopperStyles, arrowStyles: initialArrowStyles, modifiersData: {}, popperRect: initialPopperRect, targetRect: initialTargetRect, isHidden: undefined, } elemPopperWrap: HTMLDivElement | null = null popperInstance: PopperInstance | null = null targetObject: Element | null = null targetLastRect: DOMRect | null = null popperLastRect: DOMRect | null = null refArrow = createRef() // getChildNodePopper?: ChildNodePopperGetter descendantPoppersGetters: ChildNodePopperGetter[] = [] refPopperWrap = (node: HTMLDivElement) => { this.elemPopperWrap = node const refPopperWrap = this.props.refPopperWrap if (refPopperWrap) { refNode(refPopperWrap, node) } } nodePopperGetter = () => { return this } setChildPopperGetter = (childNodePopperGetter: ChildNodePopperGetter) => { /* istanbul ignore else */ if (!this.descendantPoppersGetters.includes(childNodePopperGetter)) { this.descendantPoppersGetters.push(childNodePopperGetter) } } removeChildPopperGetter = (childNodePopperGetter: ChildNodePopperGetter) => { const idx = this.descendantPoppersGetters.indexOf(childNodePopperGetter) idx > -1 && this.descendantPoppersGetters.splice(idx, 1) } get strategy() { const {strategy, positionFixed} = this.props return isUndefined(strategy) && positionFixed ? 'fixed' : strategy } get options(): Partial { const modifiers: PopperOptions['modifiers'] = [ { name: 'applyStyles', enabled: false, }, { name: 'arrow', enabled: this.props.withArrow, options: { element: this.refArrow.current || undefined, }, }, { name: 'computeStyles', options: { // firefox 下 translate3d 会导致层叠上层元素拖拽发生层叠下层元素的异常选中 // 详见 http://minerva.weiyun.baidu.com/pages/minerva.html#/main?item=f30qi3e2kh3cylahrp8 // @todo 待重新确认 gpuAcceleration: false, }, }, { name: 'updateState', enabled: true, phase: 'write', fn: this.popperUpdateStateModifier, requires: ['computeStyles'], }, ...(this.props.modifiers || []), ] return { placement: this.props.placement, strategy: this.strategy, modifiers, } } get wrapWidthStyle() { const {matchMinWidthToTarget, matchWidthToTarget} = this.props const p = this.state.placement const style: CSSProperties = {} if (!['bottom', 'top'].includes(p.split('-')[0])) { return style } const targetWidth = (this.target as Element)?.getBoundingClientRect().width if (matchWidthToTarget) { style.width = targetWidth } if (matchMinWidthToTarget) { style.minWidth = targetWidth } return style } get wrapStyle(): CSSProperties { if (!this.elemPopperWrap || !this.popperInstance) { return initialPopperStyles } const {popperStyles, targetRect, popperRect, placement, modifiersData} = this.state const arrowStyle = this.arrowStyle const [pMain, pAlign] = placement.split('-') const smallTargetStyle: CSSProperties = {} const isCenter = !pAlign // 对于带箭头的非 center 对齐,在 “小尺寸 target” 情况下, // 需要对 popper 进行偏移,保证 arrow 在对正 target 中心前提下,同时 arrow 离边缘 offset 为设计的最小值 if ( this.refArrow.current && targetRect && popperRect && !isCenter && ( (['top', 'bottom'].includes(pMain) && targetRect.width < TERMINATE_WIDTH) || (['left', 'right'].includes(pMain) && targetRect.height < TERMINATE_HEIGHT) ) ) { // arrow 里只有分别基于 top, left 的 x, y const {arrow} = modifiersData if (['top', 'bottom'].includes(pMain)) { const popperLeft = getNumberCssProp(popperStyles, 'left') const popperRight = getNumberCssProp(popperStyles, 'right') const arrowLeft = getNumberCssProp(arrowStyle, 'left') const arrowRight = getNumberCssProp(arrowStyle, 'right') // top-end, bottom-end 情况下,popper 使用 right smallTargetStyle[pAlign !== 'end' ? 'left' : 'right'] = pAlign !== 'end' ? popperLeft - arrowLeft + (arrow?.x || 0) : popperRight - arrowRight + (Math.round(popperRect.width) - (arrow?.x || 0)) } else if (['left', 'right'].includes(pMain)) { const popperTop = getNumberCssProp(popperStyles, 'top') const popperBottom = getNumberCssProp(popperStyles, 'bottom') const arrowTop = getNumberCssProp(arrowStyle, 'top') const arrowBottom = getNumberCssProp(arrowStyle, 'bottom') // right-end, left-end 情况下,popper 使用 bottom smallTargetStyle[pAlign !== 'end' ? 'top' : 'bottom'] = pAlign !== 'end' ? popperTop - arrowTop + (arrow?.y || 0) : popperBottom - arrowBottom + (popperRect.height - (arrow?.y || 0)) } } return normalizeNaNProperties({ position: initialPopperStyles.position, ...popperStyles, ...smallTargetStyle, ...this.wrapWidthStyle, }) } get arrowStyle(): CSSProperties { const {arrowOffset} = this.props const {targetRect, popperRect, placement = '', arrowStyles} = this.state const [pMain, pAlign] = placement.split('-') if (!this.refArrow.current || !targetRect || !popperRect) { return initialArrowStyles } const validArrowStyles = normalizeNaNProperties(arrowStyles) const isCenter = !pAlign // arrowOffset cases // 中心位置,arrowOffset 无效 if (isCenter) { return validArrowStyles } // custom fixed offset if (typeof arrowOffset === 'number') { return { [ARROW_INSET_NAME[placement]]: arrowOffset, } } if (arrowOffset === 'auto') { // 如果 target 元素的 3/4 宽度大于气泡的宽度,则采用边缘对齐&箭头处于气泡的中心 if ( (['top', 'bottom'].includes(pMain) && targetRect.width * 3 / 4 > popperRect.width) || (['left', 'right'].includes(pMain) && targetRect.height * 3 / 4 > popperRect.height) ) { return { [ARROW_INSET_NAME[placement]]: '50%', } } // 如果 target元素的宽高小于临界宽高,即为 “小尺寸 target” 则箭头偏移量给固定的设计值 if (['top', 'bottom'].includes(pMain) && targetRect.width < TERMINATE_WIDTH) { return { [ARROW_INSET_NAME[placement]]: DEFAULT_ARROW_OFFSET_HORIZONTAL, } } if (['left', 'right'].includes(pMain) && targetRect.height < TERMINATE_HEIGHT) { return { [ARROW_INSET_NAME[placement]]: DEFAULT_ARROW_OFFSET_VERTICAL, } } } return validArrowStyles } get target() { return getTargetObject(this.props.target) } isDescendant = (elem: Node): boolean => { return ifNodeContains(this.elemPopperWrap, elem) || this.descendantPoppersGetters.some(popperGetter => !!(popperGetter?.()?.isDescendant(elem))) } updateLastRect() { // target 和 popper 虽然实例不变,但都有可能动态改变内容 // 需要通过对比前后尺寸、位置确定是否要更新 popper 位置 this.targetLastRect = this.target?.getBoundingClientRect() || null this.popperLastRect = this.props.visible && this.elemPopperWrap ? this.elemPopperWrap.getBoundingClientRect() : null } popperUpdateStateModifier = (data: ModifierArguments>) => { const {state} = data const popperState: PopperState = { ...pick(state, [ 'placement', ]), // isHidden 的判断不能加入 state.modifiersData.hide?.hasPopperEscaped,否则在极矮条件下将总是看不到 popper isHidden: state.modifiersData.hide?.isReferenceHidden, modifiersData: state.modifiersData, popperStyles: state.styles.popper as CSSProperties, arrowStyles: state.styles.arrow as CSSProperties, popperRect: state.rects.popper, targetRect: state.rects.reference, } // console.log({popperState: state}) this.setState(popperState) safeInvoke(this.props.onChange, popperState) } destroyPopperInstance() { if (this.popperInstance) { this.popperInstance.destroy() this.popperInstance = null } } reCreatePopperInstance() { this.destroyPopperInstance() const elemTarget = this.target as Element const elemPopperWrap = this.elemPopperWrap if (elemTarget && elemPopperWrap) { this.popperInstance = createPopper(elemTarget, elemPopperWrap, this.options) } this.targetObject = elemTarget; } updatePopperInstance() { return this.popperInstance?.update() } updatePopperInstanceOptions() { return this.popperInstance?.setOptions(this.options) } renderPopperArrow = () => { if (!this.props.withArrow) { return null } const {placement} = this.state const direction = placement ? { 'top': 'down', 'bottom': 'up', 'left': 'right', 'right': 'left', }[placement.split('-')[0]] : '' const className = c( 'brick-popper-arrow', direction && `direction-${direction}` ) return (
) } renderPopper = () => { const popperProps = { ...pick(this.props, ['onMouseEnter', 'onMouseLeave']), className: 'brick-popper-wrap', style: this.wrapStyle, ref: this.refPopperWrap, } const {children} = this.props return children && (
{children}
{this.renderPopperArrow()}
) } renderPopup = () => { const popupProps = { className: c( 'brick-popper', `brick-popper-placement-${this.state.placement}`, { 'brick-popper-hidden': this.state.isHidden, 'brick-popper-with-arrow': this.props.withArrow, }, this.props.className ), ...pick(this.props, ['visible', 'destroyOnHide', 'disablePortal', 'portalContainer']), } return ( {this.renderPopper()} ) } componentWillUnmount(): void { this.destroyPopperInstance() safeInvoke(this.context.removeChildPopperFromParent, this.nodePopperGetter) } componentDidMount() { if (this.props.visible) { this.reCreatePopperInstance() } safeInvoke(this.context.setChildPopperForParent, this.nodePopperGetter) } componentDidUpdate(prevProps: Readonly>): void { const { visible, children, placement, target, positionFixed, strategy, modifiers, } = this.props const shouldReCreate = (visible && !this.popperInstance) || (children && !prevProps.children) || !isEqual(this.targetObject, this.target) || getTargetObject(target) !== getTargetObject(prevProps.target) if (shouldReCreate) { this.reCreatePopperInstance() this.updateLastRect() return } if (!visible) { this.destroyPopperInstance() this.updateLastRect() return } const shouldUpdateOptions = placement !== prevProps.placement || strategy !== prevProps.strategy || positionFixed !== prevProps.positionFixed || !isEqual(modifiers, prevProps.modifiers) if (shouldUpdateOptions) { void this.updatePopperInstanceOptions() } else if ( isRectChanged(this.targetLastRect, (this.target as HTMLElement)?.getBoundingClientRect()) || isRectChanged(this.popperLastRect, this.elemPopperWrap?.getBoundingClientRect()) ) { void this.updatePopperInstance() } this.updateLastRect() } render() { const contextValue = { ...this.context, // 逐级传递, setChildPopperForParent: this.setChildPopperGetter, removeChildPopperFromParent: this.removeChildPopperGetter, } return ( {this.renderPopup()} ) } } export const isPopper = (node: ReactNode) => { return isValidElement(node) && node.type === Popper }