【React】首页悬浮球实现,点击出现悬浮框

首页悬浮球实现

实现效果

悬浮球,可以鼠标选中然后移动到页面区域,不会超出页面区域,点击出现悬浮窗,如果再次点击悬浮球,悬浮窗关闭,不会销毁里面的元素

组件代码实现

javascript 复制代码
import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'antd';
import styles from './FloatingWrapper.less';
import chatbg from '../../imgs/chatbg.png';
import robotbg from '../../imgs/robotbg.gif';

const DRAG_CLICK_THRESHOLD_PX = 5;

const FloatingWrapper = ({ children, onOpen, bottomPos = 20, rightPos = 20 }) => {
  const [isModalVisible, setIsModalVisible] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
  const widgetRef = useRef(null);
  const rafIdRef = useRef(null);
  const pendingPosRef = useRef(null);
  const pointerIdRef = useRef(null);
  const dragStartRef = useRef({ x: 0, y: 0 });
  const didDragRef = useRef(false);

  const [modalSize, setModalSize] = useState({ width: 380, height: 580 });
  const modalResizeObserverRef = useRef(null);
  const measureOnceRafRef = useRef(null);

  const measureAndUpdateModalSize = () => {
    const selector = `.${styles.floatingModal}`;
    const modalEl = document.querySelector(selector);
    if (!modalEl) return;
    const rect = modalEl.getBoundingClientRect();
    const next = { width: Math.round(rect.width), height: Math.round(rect.height) };
    // 增加阈值,减少不必要的状态更新
    setModalSize(prev => (Math.abs(prev.width - next.width) > 5 || Math.abs(prev.height - next.height) > 5 ? next : prev));
  };

  // 初始化位置在右下角
  useEffect(() => {
    const updatePosition = () => {
      const widgetWidth = 60; // 组件宽度
      const widgetHeight = 60; // 组件高度
      const x = window.innerWidth - widgetWidth - rightPos; // 距离右边缘20px
      const y = window.innerHeight - widgetHeight - bottomPos; // 距离下边缘20px
      setPosition({ x, y });
    };

    updatePosition();
    window.addEventListener('resize', updatePosition);
    return () => window.removeEventListener('resize', updatePosition);
  }, []);

  // 处理拖拽开始
  const handleMouseDown = e => {
    e.preventDefault();


    setIsDragging(true);
    didDragRef.current = false;
    dragStartRef.current = { x: e.clientX, y: e.clientY };
    const rect = widgetRef.current.getBoundingClientRect();
    setDragOffset({
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    });

    // 捕获指针,持续接收 pointermove 事件
    if (e.pointerId != null && widgetRef.current?.setPointerCapture) {
      pointerIdRef.current = e.pointerId;
      try {
        widgetRef.current.setPointerCapture(e.pointerId);
      } catch (_) {
        // ignore
      }
    }
  };

  // 处理拖拽移动
  const handleMouseMove = e => {
    if (!isDragging) return;
    if (isModalVisible) {
      setIsModalVisible(false);
    }
    const newX = e.clientX - dragOffset.x;
    const newY = e.clientY - dragOffset.y;

    const maxX = window.innerWidth - 60;
    const maxY = window.innerHeight - 60;

    const clampedX = Math.max(0, Math.min(newX, maxX));
    const clampedY = Math.max(0, Math.min(newY, maxY));

    // 判断是否发生了可视为拖拽的移动
    if (!didDragRef.current) {
      const dx = e.clientX - dragStartRef.current.x;
      const dy = e.clientY - dragStartRef.current.y;
      if (Math.abs(dx) > DRAG_CLICK_THRESHOLD_PX || Math.abs(dy) > DRAG_CLICK_THRESHOLD_PX) {
        didDragRef.current = true;
      }
    }

    // 使用 rAF 节流 setState
    pendingPosRef.current = { x: clampedX, y: clampedY };
    if (rafIdRef.current == null) {
      rafIdRef.current = window.requestAnimationFrame(() => {
        if (pendingPosRef.current) setPosition(pendingPosRef.current);
        pendingPosRef.current = null;
        rafIdRef.current = null;
      });
    }
  };

  // 处理拖拽结束
  const handleMouseUp = e => {
    setIsDragging(false);
    if (pointerIdRef.current != null && widgetRef.current?.releasePointerCapture) {
      try {
        widgetRef.current.releasePointerCapture(pointerIdRef.current);
      } catch (_) {
        // ignore
      }
      pointerIdRef.current = null;
    }
  };

  // 组件卸载时取消 rAF
  useEffect(() => () => {
    if (rafIdRef.current != null) {
      window.cancelAnimationFrame(rafIdRef.current);
      rafIdRef.current = null;
    }
    pendingPosRef.current = null;
  }, []);

  // 拖拽时监听 document,避免鼠标移出元素丢失事件
  useEffect(() => {
    const onMove = e => handleMouseMove(e);
    const onUp = e => handleMouseUp(e);
    if (isDragging) {
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    }
    return () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    };
  }, [isDragging, dragOffset]);

  // 计算弹窗位置
  const getModalPosition = () => {
    const widgetRect = widgetRef.current?.getBoundingClientRect();
    if (!widgetRect) return {};

    const modalWidth = modalSize.width;
    const modalHeight = modalSize.height;
    const margin = 15; // 增加屏幕边距保护
    const gap = 12; // 增加与触发组件之间的间隙

    const spaceLeft = widgetRect.left;
    const spaceRight = window.innerWidth - widgetRect.right;
    const spaceTop = widgetRect.top;
    const spaceBottom = window.innerHeight - widgetRect.bottom;

    // 水平优先:默认在左侧,不够则放右侧
    let left;
    if (spaceLeft >= modalWidth + margin) {
      // 放在左侧,向右偏移 gap,避免过度贴边导致"偏左"
      left = widgetRect.left - modalWidth + gap;
    } else if (spaceRight >= modalWidth + margin) {
      // 放在右侧,向左偏移 gap
      left = widgetRect.right - gap;
    } else {
      // 两侧都不够,尽量贴边不越界
      left = Math.max(
        margin,
        Math.min(widgetRect.left - modalWidth + gap, window.innerWidth - modalWidth - margin)
      );
      if (left < margin) {
        left = Math.max(
          margin,
          Math.min(widgetRect.right - gap, window.innerWidth - modalWidth - margin)
        );
      }
    }

    // 垂直优先:默认在上方,不够则放下方
    let top;
    if (spaceTop >= modalHeight + margin) {
      top = widgetRect.top - modalHeight + gap;
    } else if (spaceBottom >= modalHeight + margin) {
      top = widgetRect.bottom - gap;
    } else {
      // 上下都不够,高度做夹紧
      const preferredTop = widgetRect.top - modalHeight + gap; // 尽量放上方
      top = Math.max(margin, Math.min(preferredTop, window.innerHeight - modalHeight - margin));
      // 如果仍然溢出,尝试放到下方再夹紧
      if (top + modalHeight > window.innerHeight - margin) {
        top = Math.max(margin, Math.min(widgetRect.bottom - gap, window.innerHeight - modalHeight - margin));
      }
    }

    // 最终防护:边界夹紧
    left = Math.max(margin, Math.min(left, window.innerWidth - modalWidth - margin));
    top = Math.max(margin, Math.min(top, window.innerHeight - modalHeight - margin));

    return { left, top };
  };

  // 监听并记录浮窗实际尺寸,驱动定位计算
  useEffect(() => {
    if (!isModalVisible) {
      if (modalResizeObserverRef.current) {
        modalResizeObserverRef.current.disconnect();
        modalResizeObserverRef.current = null;
      }
      return undefined;
    }

    const selector = `.${styles.floatingModal}`;
    const modalEl = document.querySelector(selector);
    if (!modalEl) return undefined;

    const updateSize = () => {
      const rect = modalEl.getBoundingClientRect();
      const next = { width: Math.round(rect.width), height: Math.round(rect.height) };
      // 增加阈值,减少不必要的状态更新
      setModalSize(prev => (Math.abs(prev.width - next.width) > 5 || Math.abs(prev.height - next.height) > 5 ? next : prev));
    };

    updateSize();

    if (typeof ResizeObserver !== 'undefined') {
      const ro = new ResizeObserver(() => updateSize());
      ro.observe(modalEl);
      modalResizeObserverRef.current = ro;
      return () => {
        ro.disconnect();
        modalResizeObserverRef.current = null;
      };
    }

    const intervalId = setInterval(updateSize, 250);
    return () => clearInterval(intervalId);
  }, [isModalVisible]);

  const showModal = () => {
    // NOTE 打开弹窗
    onOpen && onOpen();

    setIsModalVisible(true);

    // 等待浮窗挂载和布局,再测量一次,确保首次定位准确
    if (measureOnceRafRef.current != null) {
      cancelAnimationFrame(measureOnceRafRef.current);
      measureOnceRafRef.current = null;
    }
    measureOnceRafRef.current = requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        measureAndUpdateModalSize();
        // 延迟一点时间确保位置稳定后再显示
        measureOnceRafRef.current = null;
      });
    });
  };

  const handleCancel = () => {
    // 延迟隐藏,让淡出动画完成
    setTimeout(() => setIsModalVisible(false), 300);
  };

  return (
    <>
      <div
        ref={widgetRef}
        className={styles.floatingWidget}
        style={{
          left: `${position.x}px`,
          top: `${position.y}px`,
          cursor: isDragging ? 'grabbing' : 'grab',
          transition: isDragging ? 'none' : undefined,
        }}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      >
        <img
          className={styles.widgetIcon}
          onClick={e => {
            if (didDragRef.current || isModalVisible) {
              e.preventDefault();
              e.stopPropagation();
              return;
            }
            showModal();
          }}
          src={robotbg}
          alt=""
        />
      </div>

      <div
        className={styles.floatingModal}
        style={{
          visibility: isModalVisible ? 'visible' : 'hidden',
          position: 'fixed',
          ...getModalPosition(),
          zIndex: 1001,
          transition: 'opacity 0.3s ease-out',
        }}
      >
        <div className={styles.modalHeader}>
          <img src={chatbg} alt="robot" />
          <Icon
            className={styles.closeButton}
            onClick={handleCancel}
            type="close"
          />
        </div>
        <div className={styles.iframeContainer}>
          {children}
        </div>
      </div>
    </>
  );
};

export default FloatingWrapper;

样式

css 复制代码
.floatingWidget {
  position: fixed;
  width: 60px;
  height: 46px;
  z-index: 1000;
}

.widgetIcon {
  max-width: 100%;
  height: auto;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease;

  &:hover {
    transform: scale(1.1);
  }
}

.floatingModal {
  min-width: 350px;
  max-width: 440px;
  width: 25vw;
  background: #f7fafc;
  border-radius: 12px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
  overflow: hidden;
  will-change: opacity, transform;
  backface-visibility: hidden;
  transform: translateZ(0);
}

.modalHeader {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 40px;
  padding: 8px 12px;
  padding-bottom: 5px;
  border-radius: 8px 8px 0 0;
  position: relative;

  img {
    height: 100%;
    width: auto;
  }
}

.closeButton {
  color: rgba(0, 0, 0, 0.8);
  font-size: 14px;
}

.iframeContainer {
  min-width: 350px;
  min-height: 500px;
  max-width: 440px;
  max-height: 640px;
  height: 60vh;
  width: 100%;
  overflow: hidden;

  iframe {
    border-radius: 0 0 8px 8px;
  }
}
// 拖拽时的样式
.floatingWidget[style*='cursor: grabbing'] {
  transform: scale(1.05);
}

使用

javascript 复制代码
import React, { useRef, useState } from 'react';
import FloatingWrapper from './FloatingWrapper';
 

 

const FloatingWidget = props => {
  

  return (
    <FloatingWrapper
      onOpen={() => {
       	// 点击打开弹窗内容
      }}
    >
        <div className={styles.container}>
          <Spin spinning={loading}>
            <iframe
              src={'www.baidu.com'}
              title="百度搜索"
              width="100%"
              height="100%"
              frameBorder="0"
              allowFullScreen
              onLoad={() => {
                setLoading(false);
              }}
            />
          </Spin>
        </div>
    </FloatingWrapper>
  );
};

export default FloatingWidget;

内部放什么内容自己写,可以套iframe,也可以自己写其他的内容,这个框架

可以只要悬浮球widgetRef的部分,但是悬浮窗的实现也涉及了边界的计算,为了显示全,默认是悬浮球的左上方,对不同的边界进行了处理,所以把悬浮窗的实现也放了出来

相关推荐
隔壁的大叔2 小时前
由于vite版本不一致,导致vue组件引入报错
javascript·vue.js
申阳2 小时前
Day 11:集成百度统计以监控站点流量
前端·后端·程序员
Cache技术分享2 小时前
239. Java 集合 - 通过 Set、SortedSet 和 NavigableSet 扩展 Collection 接口
前端·后端
超级罗伯特2 小时前
大屏自适应,响应式布局,亲测有效
前端·javascript·html·大屏·驾驶舱
青衫码上行2 小时前
【Java Web学习 | 第九篇】JavaScript(3) 数组+函数
java·开发语言·前端·javascript·学习
前端老宋Running2 小时前
React组件命名为什么用小写开头会无法运行?
前端·react.js·面试
百***07182 小时前
WebSpoon9.0(KETTLE的WEB版本)编译 + tomcatdocker部署 + 远程调试教程
前端
ruanCat2 小时前
对 changelogen 和 changelogithub 使用的思考
前端·github
前端Hardy2 小时前
HTML&CSS&JS:赛博木鱼
前端·javascript·css