定位工具函数,用于将一个定宽元素放置在参照元素附近 类似 el-popover 的定位逻辑,考虑各种边界情况

4 阅读3分钟
/**
 * 定位工具函数,用于将一个定宽元素放置在参照元素附近
 * 类似 el-popover 的定位逻辑,考虑各种边界情况
 */

/**
 * 定位选项
 */
export interface PositionOptions {
  /**
   * 参照元素
   */
  reference: HTMLElement;
  /**
   * 要定位的元素
   */
  element: HTMLElement;
  /**
   * 首选放置位置
   * @default 'bottom'
   */
  placement?: 'top' | 'bottom' | 'left' | 'right';
  /**
   * 偏移量 [x, y]
   * @default [0, 0]
   */
  offset?: [number, number];
  /**
   * 边界检查配置
   * @default true (全部检查)
   * @example true | false | { top: true, right: true, bottom: true, left: true }
   */
  checkBoundary?: boolean | {
    top?: boolean;
    right?: boolean;
    bottom?: boolean;
    left?: boolean;
  };
  /**
   * 元素宽度(如果不指定则使用元素的 offsetWidth)
   */
  width?: number;
  /**
   * 元素高度(如果不指定则使用元素的 offsetHeight)
   */
  height?: number;
  /**
   * 安全距离(与视口边界的距离)
   * @default 8
   */
  safetyDistance?: number;
}

/**
 * 定位结果
 */
export interface PositionResult {
  /**
   * 最终的放置位置
   */
  placement: 'top' | 'bottom' | 'left' | 'right';
  /**
   * 元素的样式
   */
  style: {
    top: string;
    left: string;
    position: 'absolute' | 'fixed';
  };
  /**
   * 是否发生了位置调整
   */
  adjusted: boolean;
}

/**
 * 计算元素的位置
 * @param options 定位选项
 * @returns 定位结果
 */
export const calculatePosition = (options: PositionOptions): PositionResult => {
  const {
    reference,
    element,
    placement = 'bottom',
    offset = [0, 0],
    checkBoundary = {
      top: false,
      right: true,
      bottom: false,
      left: false
    },
    width,
    height,
    safetyDistance = 18
  } = options;

  // 获取参照元素的位置和尺寸
  const referenceRect = reference.getBoundingClientRect();

  // 获取要定位元素的尺寸
  const elementWidth = width || element.offsetWidth;
  const elementHeight = height || element.offsetHeight;

  // 获取视口尺寸
  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;

  // 存储计算结果
  let finalPlacement = placement;
  let finalTop = 0;
  let finalLeft = 0;
  let adjusted = false;

  // 计算各个位置的坐标
  const positions = {
    top: {
      top: referenceRect.top - elementHeight - offset[1],
      left: referenceRect.left + referenceRect.width / 2 - elementWidth / 2 + offset[0]
    },
    bottom: {
      top: referenceRect.bottom + offset[1],
      left: referenceRect.left + referenceRect.width / 2 - elementWidth / 2 + offset[0]
    },
    left: {
      top: referenceRect.top + referenceRect.height / 2 - elementHeight / 2 + offset[1],
      left: referenceRect.left - elementWidth - offset[0]
    },
    right: {
      top: referenceRect.top + referenceRect.height / 2 - elementHeight / 2 + offset[1],
      left: referenceRect.right + offset[0]
    }
  };

  // 解析边界检查配置
  const checkBoundaryConfig = typeof checkBoundary === 'boolean'
    ? {
      top: checkBoundary,
      right: checkBoundary,
      bottom: checkBoundary,
      left: checkBoundary
    }
    : {
      top: true,
      right: true,
      bottom: true,
      left: true,
      ...checkBoundary
    };

  // 检查边界并调整位置
  if (checkBoundaryConfig.top || checkBoundaryConfig.right || checkBoundaryConfig.bottom || checkBoundaryConfig.left) {
    // 首先检查首选位置是否在视口内(考虑安全距离)
    const preferredPosition = positions[placement];
    const isInViewport = {
      top: !checkBoundaryConfig.top || preferredPosition.top >= safetyDistance,
      left: !checkBoundaryConfig.left || preferredPosition.left >= safetyDistance,
      bottom: !checkBoundaryConfig.bottom || preferredPosition.top + elementHeight <= viewportHeight - safetyDistance,
      right: !checkBoundaryConfig.right || preferredPosition.left + elementWidth <= viewportWidth - safetyDistance
    };

    // 如果首选位置完全在视口内,则使用首选位置
    if (isInViewport.top && isInViewport.left && isInViewport.bottom && isInViewport.right) {
      finalTop = preferredPosition.top;
      finalLeft = preferredPosition.left;
    } else {
      // 否则尝试调整位置
      adjusted = true;

      // 首先尝试调整当前位置的坐标,而不是切换位置
      let adjustedPosition = { ...preferredPosition };
      // 调整水平位置
      if (checkBoundaryConfig.left || checkBoundaryConfig.right) {
        const maxLeft = checkBoundaryConfig.right ? viewportWidth - elementWidth - safetyDistance : Infinity;
        const minLeft = checkBoundaryConfig.left ? safetyDistance : -Infinity;

        if (adjustedPosition.left < minLeft) {
          adjustedPosition.left = minLeft;
        } else if (adjustedPosition.left > maxLeft) {
          adjustedPosition.left = maxLeft;
        }
      }

      // 调整垂直位置
      if (checkBoundaryConfig.top || checkBoundaryConfig.bottom) {
        const maxTop = checkBoundaryConfig.bottom ? viewportHeight - elementHeight - safetyDistance : Infinity;
        const minTop = checkBoundaryConfig.top ? safetyDistance : -Infinity;

        if (adjustedPosition.top < minTop) {
          adjustedPosition.top = minTop;
        } else if (adjustedPosition.top > maxTop) {
          adjustedPosition.top = maxTop;
        }
      }

      // 检查调整后的位置是否在视口内
      const adjustedIsInViewport = {
        top: !checkBoundaryConfig.top || adjustedPosition.top >= safetyDistance,
        left: !checkBoundaryConfig.left || adjustedPosition.left >= safetyDistance,
        bottom: !checkBoundaryConfig.bottom || adjustedPosition.top + elementHeight <= viewportHeight - safetyDistance,
        right: !checkBoundaryConfig.right || adjustedPosition.left + elementWidth <= viewportWidth - safetyDistance
      };

      // 如果调整后的位置在视口内,则使用调整后的位置
      if (adjustedIsInViewport.top && adjustedIsInViewport.left && adjustedIsInViewport.bottom && adjustedIsInViewport.right) {
        finalTop = adjustedPosition.top;
        finalLeft = adjustedPosition.left;
      } else {
        // 如果调整后仍然不在视口内,则尝试其他位置
        // 智能排序替代位置,优先选择与首选位置方向最接近的位置
        const getPlacementPriority = (altPlacement: string) => {
          // 定义位置之间的相似度
          const similarity: Record<string, Record<string, number>> = {
            top: { top: 0, bottom: 1, left: 2, right: 2 },
            bottom: { top: 1, bottom: 0, left: 2, right: 2 },
            left: { top: 2, bottom: 2, left: 0, right: 1 },
            right: { top: 2, bottom: 2, left: 1, right: 0 }
          };
          return similarity[placement][altPlacement];
        };

        // 生成并排序替代位置
        const otherPlacements = ['top', 'bottom', 'left', 'right']
          .filter(p => p !== placement)
          .sort((a, b) => getPlacementPriority(a) - getPlacementPriority(b));

        let foundValidPosition = false;

        for (const altPlacement of otherPlacements) {
          const altPosition = positions[altPlacement as keyof typeof positions];
          const altIsInViewport = {
            top: !checkBoundaryConfig.top || altPosition.top >= safetyDistance,
            left: !checkBoundaryConfig.left || altPosition.left >= safetyDistance,
            bottom: !checkBoundaryConfig.bottom || altPosition.top + elementHeight <= viewportHeight - safetyDistance,
            right: !checkBoundaryConfig.right || altPosition.left + elementWidth <= viewportWidth - safetyDistance
          };

          if (altIsInViewport.top && altIsInViewport.left && altIsInViewport.bottom && altIsInViewport.right) {
            finalPlacement = altPlacement as any;
            finalTop = altPosition.top;
            finalLeft = altPosition.left;
            foundValidPosition = true;
            break;
          }
        }

        // 如果没有找到完全在视口内的位置,则使用调整后的位置
        if (!foundValidPosition) {
          finalTop = adjustedPosition.top;
          finalLeft = adjustedPosition.left;
        }
      }
    }
  } else {
    // 不检查边界,直接使用首选位置
    const preferredPosition = positions[placement];
    finalTop = preferredPosition.top;
    finalLeft = preferredPosition.left;
  }
  return {
    placement: finalPlacement,
    style: {
      top: `${finalTop}px`,
      left: `${finalLeft}px`,
      position: 'fixed', // 使用 fixed 定位,基于视口
    },
    adjusted
  };
};

/**
 * 应用定位
 * @param options 定位选项
 * @returns 定位结果
 */
export const applyPosition = (options: PositionOptions): PositionResult => {
  const result = calculatePosition(options);

  // 应用样式
  Object.assign(options.element.style, result.style);

  return result;
};

/**
 * 计算箭头位置
 * @param options 定位选项
 * @param arrowSize 箭头尺寸
 * @returns 箭头样式
 */
export const calculateArrowPosition = (
  options: PositionOptions,
  arrowSize: number = 8
) => {
  const { reference, element, placement = 'bottom', offset = [0, 0] } = options;
  const referenceRect = reference.getBoundingClientRect();
  const elementRect = element.getBoundingClientRect();

  const styles: Record<string, string> = {};

  switch (placement) {
    case 'top':
      styles.bottom = `-${arrowSize}px`;
      styles.left = `${referenceRect.left + referenceRect.width / 2 - elementRect.left - arrowSize / 2 + offset[0]}px`;
      styles.transform = 'translateX(-50%)';
      break;
    case 'bottom':
      styles.top = `-${arrowSize}px`;
      styles.left = `${referenceRect.left + referenceRect.width / 2 - elementRect.left - arrowSize / 2 + offset[0]}px`;
      styles.transform = 'translateX(-50%)';
      break;
    case 'left':
      styles.right = `-${arrowSize}px`;
      styles.top = `${referenceRect.top + referenceRect.height / 2 - elementRect.top - arrowSize / 2 + offset[1]}px`;
      styles.transform = 'translateY(-50%)';
      break;
    case 'right':
      styles.left = `-${arrowSize}px`;
      styles.top = `${referenceRect.top + referenceRect.height / 2 - elementRect.top - arrowSize / 2 + offset[1]}px`;
      styles.transform = 'translateY(-50%)';
      break;
  }

  return styles;
};

/**
 * 应用箭头位置
 * @param options 定位选项
 * @param arrow 箭头元素
 * @param arrowSize 箭头尺寸
 */
export const applyArrowPosition = (
  options: PositionOptions,
  arrow: HTMLElement,
  arrowSize: number = 8
) => {
  const styles = calculateArrowPosition(options, arrowSize);
  Object.assign(arrow.style, styles);
};