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

组件代码实现
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的部分,但是悬浮窗的实现也涉及了边界的计算,为了显示全,默认是悬浮球的左上方,对不同的边界进行了处理,所以把悬浮窗的实现也放了出来