react+antd 可拖拽模态框组件

DraggableModal 可拖拽模态框组件使用说明

概述

DraggableModal 是一个基于 @dnd-kit/core 实现的可拖拽模态框组件,允许用户通过拖拽标题栏来移动模态框位置。该组件具有智能边界检测功能,确保模态框始终保持在可视区域内。

功能特性

  • 可拖拽移动:支持通过鼠标拖拽移动模态框位置
  • 智能边界检测:防止模态框被拖拽到屏幕可视区域外
  • 响应式适配:根据窗口大小自动调整可拖拽范围
  • 平滑交互:使用 CSS transform 实现流畅的拖拽动画
  • 类型安全:完整的 TypeScript 类型支持

安装依赖

确保项目中已安装以下依赖:

bash 复制代码
npm install @dnd-kit/core @dnd-kit/utilities
# 或
yarn add @dnd-kit/core @dnd-kit/utilities

基本用法

1. 导入组件

typescript 复制代码
import DraggableModal from './components/DraggableModal';

2. 基础示例

typescript 复制代码
import React, { useState } from 'react';
import { Modal } from 'antd';
import DraggableModal from './components/DraggableModal';

const ExampleComponent: React.FC = () => {
  const [visible, setVisible] = useState(false);

  return (
    <>
      <button onClick={() => setVisible(true)}>
        打开可拖拽模态框
      </button>
      
      <Modal
        title="可拖拽的模态框"
        open={visible}
        onCancel={() => setVisible(false)}
        modalRender={(modal) => (
          <DraggableModal>
            {modal}
          </DraggableModal>
        )}
      >
        <p>这是一个可以拖拽的模态框内容</p>
      </Modal>
    </>
  );
};

export default ExampleComponent;
typescript 复制代码
import React, { useState } from 'react';
import { Modal, Form, Input, Button } from 'antd';
import DraggableModal from '@/pages/StdFormEdit/components/DraggableModal';

const FormModal: React.FC = () => {
  const [visible, setVisible] = useState(false);
  const [form] = Form.useForm();

  const handleSubmit = async () => {
    try {
      const values = await form.validateFields();
      console.log('表单数据:', values);
      setVisible(false);
    } catch (error) {
      console.error('表单验证失败:', error);
    }
  };

  return (
    <>
      <Button type="primary" onClick={() => setVisible(true)}>
        打开表单模态框
      </Button>
      
      <Modal
        title="用户信息编辑"
        open={visible}
        onCancel={() => setVisible(false)}
        footer={[
          <Button key="cancel" onClick={() => setVisible(false)}>
            取消
          </Button>,
          <Button key="submit" type="primary" onClick={handleSubmit}>
            确定
          </Button>
        ]}
        modalRender={(modal) => (
          <DraggableModal>
            {modal}
          </DraggableModal>
        )}
      >
      <table ...>
      </Modal>
    </>
  );
};

export default FormModal;

API 说明

DraggableModal Props

属性 类型 默认值 说明
children ReactNode - 需要包装的模态框内容

组件内部实现细节

DraggableWrapper Props
属性 类型 说明
top number 模态框垂直位置偏移量
left number 模态框水平位置偏移量
children ReactNode 子组件内容
modalRef RefObject<HTMLDivElement> 模态框DOM引用

技术实现

核心特性

  1. 拖拽识别 :自动识别具有 modal-header 类名的元素作为拖拽手柄
  2. 边界限制
    • 垂直方向:上边界 -100px,下边界为窗口高度减去模态框高度再减去100px
    • 水平方向:限制在窗口宽度范围内,保持模态框居中对称
  3. 位置计算 :使用 CSS transform 属性实现位置变换,性能优异

边界检测算法

typescript 复制代码
// 垂直边界检测
const needRemoveMinHeight = -100; // 上边界
const needRemoveMaxHeight = winH - 100 - modalRef.current.clientHeight; // 下边界

// 水平边界检测  
const needRemoveWidth = (winW - modalRef.current.clientWidth) / 2; // 左右对称边界

使用注意事项

1. 模态框结构要求

确保被包装的模态框包含具有 modal-header 类名的标题栏元素:

html 复制代码
// ✅ 正确 - Antd Modal 自动包含 modal-header 类名
<Modal title="标题">内容</Modal>

// ❌ 错误 - 自定义模态框缺少 modal-header 类名
<div className="custom-modal">
  <div className="title">标题</div> {/* 缺少 modal-header 类名 */}
  <div>内容</div>
</div>

2. 性能优化建议

  • 避免在 DraggableModal 内部频繁更新状态
  • 对于复杂内容,建议使用 React.memo 优化子组件渲染
typescript 复制代码
const OptimizedContent = React.memo(() => {
  return (
    <div>
      {/* 复杂内容 */}
    </div>
  );
});

<DraggableModal>
  <Modal title="优化示例">
    <OptimizedContent />
  </Modal>
</DraggableModal>

DraggableModal源码

typescript 复制代码
import React, { useState, useRef, useLayoutEffect } from 'react';
import { DndContext, useDraggable } from '@dnd-kit/core';
import type { Coordinates } from '@dnd-kit/utilities';

const DraggableWrapper = (props: any) => {
    const { top, left, children: node, modalRef } = props;

    const { attributes, isDragging, listeners, setNodeRef, transform } = useDraggable({
        id: 'draggable-title'
    });

    const dragChildren = React.Children.map(node.props.children, (child) => {
        if (!child) {
            return child;
        }
        if (child.type === 'div' && child.props?.className?.indexOf('modal-header') >= 0) {
            return React.cloneElement(child, {
                'data-cypress': 'draggable-handle',
                style: { cursor: 'move' },
                ...listeners
            });
        }
        return child;
    });

    let offsetX = left;
    let offsetY = top;
    if (isDragging) {
        offsetX = left + (transform?.x ?? 0);
        offsetY = top + transform?.y;
    }

    return (
        <div
            ref={(el) => {
                setNodeRef(el);
                if (modalRef) modalRef.current = el;
            }}
            {...attributes}
            style={
                {
                    transform: `translate(${offsetX ?? 0}px, ${offsetY ?? 0}px)`
                } as React.CSSProperties
            }
        >
            {React.cloneElement(node, {}, dragChildren)}
        </div>
    );
};

const DraggableModal = (props: any) => {
    const [{ x, y }, setCoordinates] = useState<Coordinates>({
        x: 0,
        y: 0
    });
    const modalRef = useRef<HTMLDivElement>(null);
    const [modalSize, setModalSize] = useState({ width: 0, height: 0 });

    useLayoutEffect(() => {
        if (modalRef.current) {
            const rect = modalRef.current.getBoundingClientRect();
            setModalSize({ width: rect.width, height: rect.height });
        }
    }, [props.children]);

    return (
        <DndContext
            onDragEnd={({ delta }) => {
                const winW = window.innerWidth;
                const winH = window.innerHeight;
                const needRemoveMinHeight = -100;
                const needRemoveWidth = (winW - modalRef.current.clientWidth) / 2;
                const needRemoveMaxHeight = winH - 100 - modalRef.current.clientHeight;
                const newX = x + delta.x;
                const newY = y + delta.y;
                let curNewY = newY;
                if (newY < 0) {
                    curNewY = newY < needRemoveMinHeight ? needRemoveMinHeight : newY;
                } else {
                    curNewY = newY > needRemoveMaxHeight ? needRemoveMaxHeight : newY;
                }
                if (Math.abs(newX) < needRemoveWidth) {
                    setCoordinates({
                        x: newX,
                        y: curNewY
                    });
                } else {
                    setCoordinates({
                        x: newX < 0 ? 0 - needRemoveWidth : needRemoveWidth,
                        y: curNewY
                    });
                }
            }}
        >
            <DraggableWrapper top={y} left={x} modalRef={modalRef}>
                {props.children}
            </DraggableWrapper>
        </DndContext>
    );
};

export default DraggableModal;

3. 兼容性说明

  • 支持现代浏览器(Chrome 88+、Firefox 84+、Safari 14+)
  • 移动端暂不支持拖拽功能
  • 需要 React 16.8+ 版本支持

故障排除

常见问题

Q: 模态框无法拖拽?

A: 检查以下几点:

  1. 确保模态框标题栏包含 modal-header 类名
  2. 确认 @dnd-kit/core 依赖已正确安装
  3. 检查是否有其他元素阻止了拖拽事件

Q: 拖拽时出现跳跃现象?

A: 这通常是由于 CSS 样式冲突导致,确保没有其他 transform 样式影响模态框定位。

Q: 模态框被拖拽到屏幕外?

A: 组件内置了边界检测,如果出现此问题,请检查窗口大小变化时是否正确触发了重新计算。

版本历史

  • v1.0.0: 初始版本,支持基础拖拽功能和边界检测
相关推荐
支撑前端荣耀14 分钟前
十、Cypress最佳实践——写出高效又好维护的测试
前端
胡gh15 分钟前
一篇文章,带你搞懂大厂如何考察你对Array的理解
javascript·后端·面试
_一两风23 分钟前
深入浅出Babel:现代JavaScript开发的"翻译官"
前端·babel
胡gh24 分钟前
this 与 bind:JavaScript 中的“归属感”难题
javascript·设计模式·程序员
3Katrina25 分钟前
深入理解React中的受控组件与非受控组件
前端
_一两风25 分钟前
如何从零开始创建一个 React 项目
前端·react.js
拾光拾趣录36 分钟前
两两交换链表节点
前端·算法
OEC小胖胖40 分钟前
前端性能优化“核武器”:新一代图片格式(AVIF/WebP)与自动化优化流程实战
前端·javascript·性能优化·自动化·web
~央千澈~1 小时前
laravel RedisException: Connection refused优雅草PMS项目管理系统报错解决-以及Redis 详细指南-优雅草卓伊凡
前端·redis·html·php
爬点儿啥1 小时前
[JS逆向] 微信小程序逆向工程实战
开发语言·javascript·爬虫·微信小程序·逆向