​​React 可拖拽缩放容器组件 ResizableBox 设计与实现​​

388 阅读8分钟

​React 可拖拽缩放容器组件 ResizableBox 设计与实现​

​📌 前因后果:为什么需要这个组件?​

在开发 React 应用时,我们经常需要实现可拖拽、可调整大小的 UI 组件,例如:

  • ​仪表盘面板​​(Dashboard Panel)
  • ​可调整大小的弹窗​​(Resizable Modal)
  • ​可拖拽的浮动工具栏​​(Draggable Toolbar)
  • ​自定义布局编辑器​​(Layout Editor)

虽然市面上有一些成熟的库(如 react-resizablereact-draggable),但它们往往:

  1. ​功能单一​​(只支持拖拽或只支持缩放)
  2. ​API 复杂​​(需要组合多个 HOC)
  3. ​不够灵活​​(难以自定义安全边距、限制范围等)

因此,我决定自己实现一个 ​ResizableBox​,它支持:
✅ ​​8 个方向调整大小​​(N, NE, E, SE, S, SW, W, NW)
✅ ​​自由拖拽移动​
✅ ​​安全边距限制​​(防止超出视口)
✅ ​​最小/最大尺寸限制​
✅ ​​自定义边缘手柄​​(可禁用某些方向的调整)

基本用法

import ResizableBox from "./xxx";

function App() {
  return (
    <ResizableBox
      defaultWidth={300}
      defaultHeight={200}
      minWidth={100}
      minHeight={100}
    >
      <div>可以拖拽和调整大小的内容区域</div>
    </ResizableBox>
  );
}

代码如下

import React, { useRef, useState, useCallback, useEffect } from "react";
import { throttle } from "lodash"; // 引入lodash的节流函数

// 定义拖拽方向的类型,包括8个方向和移动
type DragDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw" | "move";

// 定义组件属性接口
interface StretchableProps {
  children: React.ReactNode; // 组件内容
  defaultWidth?: number; // 默认宽度
  defaultHeight?: number; // 默认高度
  defaultX?: number; // 默认X坐标
  defaultY?: number; // 默认Y坐标
  minWidth?: number; // 最小宽度
  minHeight?: number; // 最小高度
  maxWidth?: number; // 最大宽度
  maxHeight?: number; // 最大高度
  onResize?: (width: number, height: number) => void; // 尺寸变化回调
  onMove?: (x: number, y: number) => void; // 位置变化回调
  style?: React.CSSProperties; // 自定义样式
  contentStyle?: React.CSSProperties; // 内容区域样式
  enabledEdges?: DragDirection[]; // 启用的边缘
  safeArea?: {
    // 安全边距
    top?: number; // 顶部安全距离
    right?: number; // 右侧安全距离
    bottom?: number; // 底部安全距离
    left?: number; // 左侧安全距离
  };
}

/*
@example 一个可以调整大小和拖动的容器。
  <DragResizeBox 
        defaultWidth={300}
        defaultHeight={200}
        defaultX={0}
        defaultY={0}
        minWidth={150}
        minHeight={150}
        safeArea={{ top: 50, right: 50, bottom: 50, left: 50 }}
      >
        <div>
          <h3>内容区域</h3>
          <p>这是一个可拖拽和拉伸的面板</p>
          <p>尝试拖动标题栏移动面板</p>
          <p>尝试拖动边缘或角落调整大小</p>
        </div>
      </DragResizeBox >
*/

const DragResizeBox : React.FC<StretchableProps> = ({
  children,
  defaultWidth = 300, // 默认宽度300px
  defaultHeight = 200, // 默认高度200px
  defaultX = 0, // 默认X位置0
  defaultY = 0, // 默认Y位置0
  minWidth = 100, // 最小宽度100px
  minHeight = 100, // 最小高度100px
  maxWidth = Infinity, // 最大宽度无限制
  maxHeight = Infinity, // 最大高度无限制
  onResize, // 尺寸变化回调函数
  onMove, // 位置变化回调函数
  style, // 自定义样式
  contentStyle, // 内容区域样式
  enabledEdges = ["n", "ne", "e", "se", "s", "sw", "w", "nw"], // 默认启用所有边缘
  safeArea = {}, // 安全边距默认空对象
}) => {
  // 容器元素的引用
  const containerRef = useRef<HTMLDivElement>(null);

  // 组件尺寸状态
  const [size, setSize] = useState({
    width: defaultWidth, // 当前宽度
    height: defaultHeight, // 当前高度
  });

  // 组件位置状态
  const [position, setPosition] = useState({
    x: defaultX, // 当前X坐标
    y: defaultY, // 当前Y坐标
  });

  // 是否正在拖拽的状态
  const [isDragging, setIsDragging] = useState(false);

  // 当前拖拽方向的状态
  const [dragDirection, setDragDirection] = useState<DragDirection | null>(
    null
  );

  // 是否正在拉伸的状态
  const [isResizing, setIsResizing] = useState(false);

  // 拖拽信息引用,记录拖拽开始时的状态
  const dragInfo = useRef({
    startX: 0, // 鼠标按下时的X坐标
    startY: 0, // 鼠标按下时的Y坐标
    startWidth: 0, // 拖拽开始时的宽度
    startHeight: 0, // 拖拽开始时的高度
    startLeft: 0, // 拖拽开始时的left位置
    startTop: 0, // 拖拽开始时的top位置
  });

  // 解构安全边距,如果没有提供则默认为0
  const {
    top: safeTop = 0, // 顶部安全距离,默认0
    right: safeRight = 0, // 右侧安全距离,默认0
    bottom: safeBottom = 0, // 底部安全距离,默认0
    left: safeLeft = 0, // 左侧安全距离,默认0
  } = safeArea;

  /**
   * 应用安全边距限制
   * @param x 当前X坐标
   * @param y 当前Y坐标
   * @param width 当前宽度
   * @param height 当前高度
   * @returns 受安全边距约束后的位置和尺寸
   */
  const applySafeAreaConstraints = useCallback(
    (x: number, y: number, width: number, height: number) => {
      // 计算最大允许的x和y坐标
      const maxX = window.innerWidth - width - safeRight;
      const maxY = window.innerHeight - height - safeBottom;

      // 限制x和y在安全区域内
      const constrainedX = Math.max(safeLeft, Math.min(maxX, x));
      const constrainedY = Math.max(safeTop, Math.min(maxY, y));

      // 如果宽度或高度会导致超出安全区域,则需要调整
      let constrainedWidth = width;
      let constrainedHeight = height;

      // 检查右边是否超出安全区域
      if (constrainedX + constrainedWidth > window.innerWidth - safeRight) {
        constrainedWidth = window.innerWidth - safeRight - constrainedX;
      }

      // 检查底部是否超出安全区域
      if (constrainedY + constrainedHeight > window.innerHeight - safeBottom) {
        constrainedHeight = window.innerHeight - safeBottom - constrainedY;
      }

      // 确保调整后的尺寸不小于最小尺寸
      constrainedWidth = Math.max(minWidth, constrainedWidth);
      constrainedHeight = Math.max(minHeight, constrainedHeight);

      return {
        x: constrainedX,
        y: constrainedY,
        width: constrainedWidth,
        height: constrainedHeight,
      };
    },
    [safeTop, safeRight, safeBottom, safeLeft, minWidth, minHeight]
  );

  /**
   * 判断是否已达到最小尺寸
   * @returns 是否达到最小尺寸
   */
  const isMinSize = useCallback(() => {
    return size.width <= minWidth || size.height <= minHeight;
  }, [size.width, size.height, minWidth, minHeight]);

  /**
   * 鼠标按下事件处理
   * @param direction 拖拽方向
   * @param e 鼠标事件
   */
  const handleMouseDown = useCallback(
    (direction: DragDirection, e: React.MouseEvent) => {
      e.preventDefault(); // 阻止默认行为
      e.stopPropagation(); // 阻止事件冒泡

      if (!containerRef.current) return; // 如果容器不存在则返回

      // 检查当前方向是否被启用
      if (!enabledEdges.includes(direction) && direction !== "move") {
        return;
      }

      // 获取容器当前的位置和尺寸
      const rect = containerRef.current.getBoundingClientRect();
      // 记录拖拽开始时的信息
      dragInfo.current = {
        startX: e.clientX,
        startY: e.clientY,
        startWidth: rect.width,
        startHeight: rect.height,
        startLeft: rect.left,
        startTop: rect.top,
      };

      // 设置拖拽方向和状态
      setDragDirection(direction);
      setIsDragging(true);
      setIsResizing(direction !== "move");

      // 根据拖拽方向设置鼠标样式
      let cursor = "";
      switch (direction) {
        case "n":
        case "s":
          cursor = "ns-resize"; // 南北方向调整
          break;
        case "e":
        case "w":
          cursor = "ew-resize"; // 东西方向调整
          break;
        case "ne":
        case "sw":
          cursor = "nesw-resize"; // 东北-西南方向调整
          break;
        case "nw":
        case "se":
          cursor = "nwse-resize"; // 西北-东南方向调整
          break;
        case "move":
          cursor = isMinSize() ? "not-allowed" : "move"; // 移动时根据最小尺寸判断
          break;
      }
      // 设置全局鼠标样式和禁用文本选择
      document.body.style.cursor = cursor;
      document.body.style.userSelect = "none";
    },
    [isMinSize, enabledEdges]
  );

  /**
   * 鼠标移动事件处理(使用节流函数优化性能)
   */
  const handleMouseMove = useCallback(
    throttle((e: MouseEvent) => {
      // 如果没有拖拽或没有方向或容器不存在则返回
      if (!isDragging || !dragDirection || !containerRef.current) return;

      // 获取拖拽开始时的信息
      const { startX, startY, startWidth, startHeight, startLeft, startTop } =
        dragInfo.current;
      // 计算鼠标移动的偏移量
      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;

      // 初始化新的尺寸和位置
      let newWidth = startWidth;
      let newHeight = startHeight;
      let newLeft = startLeft;
      let newTop = startTop;

      // 如果是调整大小操作
      if (isResizing) {
        switch (dragDirection) {
          case "e": // 东边调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth + deltaX)
            );
            break;
          case "w": // 西边调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth - deltaX)
            );
            newLeft = startLeft + (startWidth - newWidth); // 保持右边不动
            break;
          case "s": // 南边调整
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight + deltaY)
            );
            break;
          case "n": // 北边调整
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight - deltaY)
            );
            newTop = startTop + (startHeight - newHeight); // 保持底边不动
            break;
          case "ne": // 东北角调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth + deltaX)
            );
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight - deltaY)
            );
            newTop = startTop + (startHeight - newHeight);
            break;
          case "nw": // 西北角调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth - deltaX)
            );
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight - deltaY)
            );
            newLeft = startLeft + (startWidth - newWidth);
            newTop = startTop + (startHeight - newHeight);
            break;
          case "se": // 东南角调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth + deltaX)
            );
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight + deltaY)
            );
            break;
          case "sw": // 西南角调整
            newWidth = Math.max(
              minWidth,
              Math.min(maxWidth, startWidth - deltaX)
            );
            newHeight = Math.max(
              minHeight,
              Math.min(maxHeight, startHeight + deltaY)
            );
            newLeft = startLeft + (startWidth - newWidth);
            break;
        }
      } else if (dragDirection === "move") {
        // 如果是移动操作
        newLeft += deltaX;
        newTop += deltaY;
      }

      // 应用安全边距限制
      const constrained = applySafeAreaConstraints(
        newLeft,
        newTop,
        newWidth,
        newHeight
      );

      // 更新容器样式
      containerRef.current.style.width = `${constrained.width}px`;
      containerRef.current.style.height = `${constrained.height}px`;
      containerRef.current.style.left = `${constrained.x}px`;
      containerRef.current.style.top = `${constrained.y}px`;

      // 更新状态
      if (dragDirection === "move") {
        // 如果是移动操作,只更新位置
        setPosition({ x: constrained.x, y: constrained.y });
      } else {
        // 如果是调整大小操作,更新尺寸和位置
        setSize({ width: constrained.width, height: constrained.height });
        setPosition({ x: constrained.x, y: constrained.y });
      }
    }, 16), // 节流时间16ms
    [
      isDragging,
      dragDirection,
      minWidth,
      maxWidth,
      minHeight,
      maxHeight,
      isResizing,
      applySafeAreaConstraints,
    ]
  );

  /**
   * 鼠标释放事件处理
   */
  const handleMouseUp = useCallback(() => {
    if (!isDragging) return; // 如果没有拖拽则返回

    // 重置拖拽状态
    setIsDragging(false);
    setIsResizing(false);
    setDragDirection(null);
    // 恢复鼠标样式和文本选择
    document.body.style.cursor = "";
    document.body.style.userSelect = "";

    // 调用回调函数
    if (dragDirection === "move") {
      onMove?.(position.x, position.y); // 如果是移动操作,调用onMove
    } else {
      onResize?.(size.width, size.height); // 如果是调整大小操作,调用onResize
      onMove?.(position.x, position.y); // 同时调用onMove
    }
  }, [isDragging, dragDirection, position, size, onResize, onMove]);

  // 监听拖拽状态变化,添加/移除鼠标移动和释放事件
  useEffect(() => {
    if (isDragging) {
      document.addEventListener("mousemove", handleMouseMove);
      document.addEventListener("mouseup", handleMouseUp);
    }

    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
      handleMouseMove.cancel(); // 取消节流函数
    };
  }, [isDragging, handleMouseMove, handleMouseUp]);

  // 初始化时应用安全边距
  useEffect(() => {
    if (containerRef.current) {
      // 计算受约束的位置和尺寸
      const constrained = applySafeAreaConstraints(
        position.x,
        position.y,
        size.width,
        size.height
      );

      // 更新容器样式
      containerRef.current.style.width = `${constrained.width}px`;
      containerRef.current.style.height = `${constrained.height}px`;
      containerRef.current.style.left = `${constrained.x}px`;
      containerRef.current.style.top = `${constrained.y}px`;

      // 更新状态
      setSize({ width: constrained.width, height: constrained.height });
      setPosition({ x: constrained.x, y: constrained.y });
    }
  }, []); // 空依赖数组表示只在组件挂载时执行

  /**
   * 渲染可拉伸的边缘手柄
   * @returns 边缘手柄元素数组
   */
  const renderEdgeHandles = () => {
    // 定义所有可拉伸边缘的配置
    const edges = [
      {
        direction: "n", // 北边
        style: {
          top: 0,
          left: 0,
          right: 0,
          height: "5px",
          cursor: "ns-resize",
        },
      },
      {
        direction: "e", // 东边
        style: {
          top: 0,
          right: 0,
          bottom: 0,
          width: "5px",
          cursor: "ew-resize",
        },
      },
      {
        direction: "s", // 南边
        style: {
          bottom: 0,
          left: 0,
          right: 0,
          height: "5px",
          cursor: "ns-resize",
        },
      },
      {
        direction: "w", // 西边
        style: {
          top: 0,
          left: 0,
          bottom: 0,
          width: "5px",
          cursor: "ew-resize",
        },
      },
      {
        direction: "ne", // 东北角
        style: {
          top: 0,
          right: 0,
          width: "10px",
          height: "10px",
          cursor: "nesw-resize",
        },
      },
      {
        direction: "se", // 东南角
        style: {
          bottom: 0,
          right: 0,
          width: "10px",
          height: "10px",
          cursor: "nwse-resize",
        },
      },
      {
        direction: "sw", // 西南角
        style: {
          bottom: 0,
          left: 0,
          width: "10px",
          height: "10px",
          cursor: "nesw-resize",
        },
      },
      {
        direction: "nw", // 西北角
        style: {
          top: 0,
          left: 0,
          width: "10px",
          height: "10px",
          cursor: "nwse-resize",
        },
      },
    ];

    // 过滤出启用的边缘并渲染
    return edges
      .filter((edge) => enabledEdges.includes(edge.direction as DragDirection))
      .map((edge) => (
        <div
          key={edge.direction} // 唯一key
          onMouseDown={(e) =>
            handleMouseDown(edge.direction as DragDirection, e)
          }
          style={{
            position: "absolute",
            zIndex: edge.direction.length === 1 ? 10 : 20, // 角落手柄z-index更高
            ...edge.style,
          }}
        />
      ));
  };

  // 组件渲染
  return (
    <div
      ref={containerRef} // 容器引用
      style={{
        position: "absolute", // 绝对定位
        width: `${size.width}px`, // 当前宽度
        height: `${size.height}px`, // 当前高度
        left: `${position.x}px`, // 当前X位置
        top: `${position.y}px`, // 当前Y位置
        border: "1px solid #ccc", // 边框样式
        background: "#fff", // 背景色
        boxShadow: "0 2px 10px rgba(0,0,0,0.1)", // 阴影
        borderRadius: "4px", // 圆角
        overflow: "hidden", // 溢出隐藏
        userSelect: "none", // 禁用文本选择
        cursor: isResizing ? "default" : "move", // 鼠标样式
        ...style, // 合并自定义样式
      }}
      onMouseDown={(e) => handleMouseDown("move", e)} // 移动事件
    >
      {/* 内容区域 */}
      <div
        style={{
          padding: "12px", // 内边距
          height: "100%", // 高度100%
          overflow: "auto", // 溢出滚动
          ...contentStyle, // 合并内容样式
        }}
      >
        {children} {/* 渲染子内容 */}
      </div>

      {/* 渲染启用的边缘手柄 */}
      {(!isDragging || isResizing) && renderEdgeHandles()}
    </div>
  );
};

export default DragResizeBox;