React Rnd实现自由拖动与边界检测

AI 问答烂大街的今天,只要是个平台都在搞聊天机器人,主要应用场景无非是问答式的"智能"客服。

笔者所在小团队维护着一个OA系统(技术栈:react17 + webpack5.0 + tailwindcss + ArcoDesign),该系统沉淀了很多知识文档,经过后端同学的努力(据说是用了开源框架实现文档的Embedding)将其文档知识化,搭配司内部署的DeepSeek,基本实现了boss要求的智能问答。

智能问答UI

因为是内部OA系统,使用者基本是HR小姐姐,她们给的需求基本是一句话描述,哈哈哈,也挺好,方便笔者自由发挥。 参(抄)考(袭)类似平台的交互,决定采用右侧抽屉的形式底部一个输入框,上方展示消息区:

搞事1.0

HR小姐姐: 这个对话框挺好用的,只是固定在页面左侧,能不能像微信一样自由拖动到屏幕其他位置?

我:额,这是前端页面,全屏幕拖动可实现不了,在当前页面拖动还可以努力一下。
HR小姐姐: 当前页面拖动不就是在显示屏上拖动嘛,有什么区别呢~

我:额...

实现

要拖动是吧,那还不简单,都2025年了,拖动还不是随便找个库就可以打发实现。

首先想到的便是 react-dnd ,但是吧,这个库能力很强大,用来做单组件的拖动太杀鸡用牛刀了。而且笔者不喜欢重复以往的方案,喜欢用点新玩意儿。

继续 Searching,啊哈,还真找着了一个轻量且合适的库:react-rnd

其文档中的例子和契合本次需求:

说干就干,引入 react-rnd,按照官方文档进行配置:

js 复制代码
import { Rnd } from "react-rnd";

export function DragForm() {
  return (
    <Rnd
      default={{
        x: 0,
        y: 0,
        width: 500,
        height: 190,
      }}
      minWidth={500}
      minHeight={190}
      bounds="window"
    >
      <div className="drag__content">
        <MyForm />
      </div>
    </Rnd>
  );
}

优化1完成,交差!

搞事1.1

HR小姐姐: 可以拖动啦,真的很丝滑唉~,能不能支持折叠?有时候我想看页面主体内容但是又不想关掉弹窗,关掉再打开历史记录就没了(当时着急交差,还不支持查看历史会话)。

好吧,我就知道没那么简单~

继续优化,折叠嘛,用上 Collapse 组件不就行了。

js 复制代码
// 下面为 demo 代码,使用简易 Form 代替了 Drawer 内容
export function DragForm() {
  return (
    <Rnd
      default={{
        x: 0,
        y: 0,
        width: 500,
        height: 190,
      }}
      minWidth={500}
      minHeight={190}
      bounds="window"
    >
      <div className="drag__content">
        <Collapse expandIcon className="collapse__header" defaultActiveKey="1">
          <CollapseItem name="1" header="折叠面板">
            <MyForm />
          </CollapseItem>
        </Collapse>
      </div>
    </Rnd>
  );
}

嗯,能拖动,能折叠,这回够用了吧。

搞事2.0

HR小姐姐: 不对啊,我这边展开内容后,下面部分被挡住了:

能不能让被挡住部分自动移动上来?不要让我手工调整位置?

好吧好吧,继续优化~

初步优化

首先分析问题原因:

Collapse 展开、收起时会引发容器高度的变化,但是没有触发 rnd 容器的位置变化,造成内容被遮挡了,所以解决办法是监听 CollapseonChange 事件,在回调里重新定位,防止rnd 容器溢出可视范围。

效果:

进一步优化

上面的处理可以解决 Collapse 展开时被遮挡问题,但是用户可以无限制拖动 Collapse,将 Collapse 拖动到视窗之外,所以还需要给拖动加上一个边界,防止内容被拖动到视窗之外。

这里有两种实现:

  1. 限定拖动范围,当Collapse离开视窗时禁止拖动
  2. 增加兜底逻辑,当最后防止位置不在视窗范围内时,自动将Collapse定位到最低可视范围内

综合用户体验,决定采用第二种实现。

提取工具类

为了满足拖动后自动定位,需要实现一个工具函数,笔者刚开始写的时候确实是定义了一个function,最后发现内联方法越来越多,最后重构为一个 Class:

JavaScript 复制代码
export interface IPositionData {
  x: number;
  y: number;
}
export interface ISizeData {
  width: number;
  height: number;
}

export interface IHelpPanelRectData {
  defaultRightOffset: number;
  defaultBottomOffset: number;
  defaultTopOffset: number;
  defaultSize: ISizeData;
}

export const getDefaultRect = (data: IHelpPanelRectData) => {
  const winWidth = window.innerWidth;
  const winHeight = window.innerHeight;
  const width = Math.min(
    winWidth - data.defaultRightOffset,
    data.defaultSize.width
  );
  const height = Math.min(
    winHeight - data.defaultBottomOffset - data.defaultTopOffset,
    data.defaultSize.height
  );
  const position = {
    x: winWidth - width - data.defaultRightOffset,
    y: winHeight - height - data.defaultBottomOffset,
  };
  return { size: { width, height }, position };
};

export default class HelpPanelRect {
  // 拖动容器最小高度
  public minHeight = 100;
  // 拖动容器最小宽度
  public minWidth = 400;
  // 拖动容器最大高度
  public maxHeight = 1024;
  // 拖动容器最大宽度
  public maxWidth = 1024;
  // 默认上边距
  public defaultTopOffset = 20;
  // 当前上边距
  public topOffset = 20;
  // 默认右边距
  public defaultRightOffset = 20;
  // 当前右边距
  public rightOffset = 20;
  // 默认情况下距底高度,紧贴帮助按钮
  public defaultBottomOffset = 20;
  // 面板移动之后底部边距,0 表示紧贴窗口
  public bottomOffset = 20;
  // 默认大小
  public defaultSize = {
    width: 450,
    height: 457,
  };
  // 最大大小
  public maxSize = {
    width: this.maxWidth,
    height: this.maxHeight,
  };

  private prevSize: ISizeData;
  private prevPosition: IPositionData;

  constructor(data: IHelpPanelRectData) {
    this.defaultRightOffset = data.defaultRightOffset;
    this.defaultBottomOffset = data.defaultBottomOffset;
    this.defaultTopOffset = data.defaultTopOffset;
    this.defaultSize = data.defaultSize;

    this.prevSize = this.defaultSize;
    this.prevPosition = this.getDefaultRect(data).position;
  }

  // 点击入口按钮时的初始状态
  getDefaultRect(data?: IHelpPanelRectData) {
    const defaultRect = data ? getDefaultRect(data) : getDefaultRect(this);
    this.prevSize = defaultRect.size;
    return {
      ...defaultRect,
    };
  }

  /**
   *
   * @param windowIsResize 是否拖动浏览器窗口
   */
  getRectByWindowResize(windowIsResize = false) {
    const winWidth = window.innerWidth;
    const winHeight = window.innerHeight;
    let { x, y } = this.prevPosition;
    y = Math.max(y, this.topOffset);

    const getMaxHeight = () =>
      Math.max(
        this.minHeight,
        Math.min(winHeight - y - this.bottomOffset, this.maxHeight)
      );

    let { width, height } = this.prevSize;
    if (this.rightOffset + x + width > winWidth) {
      if (x > 0) {
        x = Math.max(winWidth - width - this.rightOffset, 0);
      } else {
        width = Math.max(
          this.minWidth,
          Math.min(winWidth - this.rightOffset, this.maxWidth)
        );
      }
    }

    if (y + height + this.bottomOffset > winHeight) {
      if (y > this.topOffset) {
        y = Math.max(this.topOffset, winHeight - height - this.bottomOffset);
      } else {
        height = Math.max(
          this.minHeight,
          Math.min(
            winHeight - this.topOffset - this.bottomOffset,
            this.maxHeight
          )
        );
      }
    } else {
      height = windowIsResize
        ? getMaxHeight()
        : Math.min(height, winHeight - this.bottomOffset - this.topOffset);
    }

    return { size: { width, height }, position: { x, y } };
  }

  /**
   * 尺寸不变,只调整位置
   * @param position 拖动之后的位置
   */
  getRectByPosition(position: IPositionData) {
    const winWidth = window.innerWidth;
    const winHeight = window.innerHeight;
    const { width, height } = this.prevSize;

    this.prevPosition = {
      x: Math.min(Math.max(0, position.x), winWidth - width - this.rightOffset),
      y: Math.min(
        Math.max(0, this.topOffset, position.y),
        winHeight - height - this.bottomOffset
      ),
    };

    return { size: { width, height }, position: this.prevPosition };
  }

  getRectByResize(pos?: IPositionData, s?: ISizeData) {
    this.prevSize = s ?? this.prevSize;

    this.prevPosition = pos ?? this.prevPosition;

    const { size, position } = this.getRectByWindowResize();
    this.prevSize = size;
    this.prevPosition = position;
    return { size, position };
  }

  getRect() {
    return { position: this.prevPosition, size: this.prevSize };
  }

  setRect(position?: IPositionData, size?: ISizeData) {
    this.prevPosition = position ?? this.prevPosition;
    this.prevSize = size ?? this.prevSize;
  }

  setDefaultSize(size: ISizeData) {
    this.defaultSize = size;
  }

  setSize(size: ISizeData) {
    this.prevSize = size;
  }

  resetSize() {
    this.prevSize = this.defaultSize;
  }

  resetPosition() {
    const winWidth = window.innerWidth;
    const winHeight = window.innerHeight;
    const { height, width } = this.prevSize;

    this.prevPosition = {
      x: winWidth - width - this.defaultRightOffset,
      y: winHeight - height - this.defaultBottomOffset,
    };
  }

  /**
   * 重置布局
   */
  resetRect() {
    this.resetSize();
    this.resetPosition();
  }
}

使用示例:

JavaScript 复制代码
import { Rnd } from "react-rnd";
import {
  Form,
  Input,
  Checkbox,
  Button,
  Radio,
  Collapse,
} from "@arco-design/web-react";
import "./DragForm.less";
import { useRef, useState, useLayoutEffect } from "react";
import HelpPanelRect from "../utils/HelpPanelRect";

const FormItem = Form.Item;
const RadioGroup = Radio.Group;
const CollapseItem = Collapse.Item;

const helpPanelRect = new HelpPanelRect({
  defaultRightOffset: 20,
  defaultBottomOffset: 20,
  defaultTopOffset: 20,
  defaultSize: {
    width: 400,
    height: 300,
  },
});

/**
 *
 */
export function DragForm() {
  const panelRef = useRef<HTMLDivElement>(null);

  // 浮窗大小
  const [floatPanelSize, setFloatPanelSize] = useState(
    helpPanelRect.defaultSize
  );
  // 浮窗坐标
  const [floatPanelPosition, setFloatPanelPosition] = useState(
    helpPanelRect.getDefaultRect().position
  );

  useLayoutEffect(() => {
    if (!panelRef.current) {
      return;
    }

    const target = panelRef.current;
    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        if (
          width === floatPanelSize.width &&
          height === floatPanelSize.height
        ) {
          continue;
        }
        setFloatPanelSize({ width, height });
        helpPanelRect.setDefaultSize({ width, height });
        // 更新面板高度
        const newSize = {
          ...floatPanelSize,
          height,
        };

        // 获取当前位置并调整,确保面板不会超出视口
        const currentRect = helpPanelRect.getRectByResize(
          floatPanelPosition,
          newSize
        );

        setFloatPanelSize(currentRect.size);
        setFloatPanelPosition(currentRect.position);
      }
    });
    if (target) {
      resizeObserver.observe(target);
    }

    return () => {
      resizeObserver.disconnect();
    };
  }, [panelRef, floatPanelSize, floatPanelPosition]);

  return (
    <Rnd
      style={{
        pointerEvents: "all",
        zIndex: 101,
      }}
      className="float-panel"
      disableDragging={false}
      enableResizing={{
        bottom: false,
        bottomLeft: false,
        bottomRight: false,
        left: false,
        right: false,
        top: false,
        topLeft: false,
        topRight: false,
      }}
      position={floatPanelPosition}
      size={floatPanelSize}
      maxWidth={helpPanelRect.maxWidth}
      maxHeight={helpPanelRect.maxHeight}
      minHeight={helpPanelRect.minHeight}
      minWidth={helpPanelRect.minWidth}
      onResizeStop={(e, direction, ref, delta, position) => {
        const currentRect = helpPanelRect.getRectByResize(position, {
          width: parseInt(ref.style.width, 10),
          height: parseInt(ref.style.height, 10),
        });
        setFloatPanelSize(currentRect.size);
        setFloatPanelPosition(currentRect.position);
      }}
      onDragStop={(e, data) => {
        const currentRect = helpPanelRect.getRectByPosition(data);
        setFloatPanelSize(currentRect.size);
        setFloatPanelPosition(currentRect.position);
      }}
      dragHandleClassName="drag__content"
    >
      <div ref={panelRef} className="drag__content">
        <header className="drag__header">拖动区域</header>
        <Collapse expandIcon className="collapse__header" defaultActiveKey="1">
          <CollapseItem name="1" header="折叠面板">
            <MyForm />
          </CollapseItem>
        </Collapse>
      </div>
    </Rnd>
  );
}

function MyForm() {
  return (
    <Form
      style={{
        maxWidth: 600,
      }}
      autoComplete="off"
      layout={"vertical"}
    >
      <FormItem label="Layout">
        <RadioGroup type="button" name="layout">
          <Radio value="horizontal">horizontal</Radio>
          <Radio value="vertical">vertical</Radio>
          <Radio value="inline">inline</Radio>
        </RadioGroup>
      </FormItem>
      <FormItem
        label="Username"
        field="username"
        tooltip={<div>Username is required </div>}
        rules={[{ required: true }]}
      >
        <Input style={{ width: 270 }} placeholder="please enter your name" />
      </FormItem>
      <FormItem label="Post">
        <Input style={{ width: 270 }} placeholder="please enter your post" />
      </FormItem>
      <FormItem wrapperCol={{}}>
        <Checkbox>I have read the manual</Checkbox>
      </FormItem>
      <FormItem wrapperCol={{}}>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </FormItem>
    </Form>
  );
}

上面的代码基本演示了 HelpPanelRect 的使用。

PS: 第一步优化中监听 CollapseonChange 事件来重新定位的实现被我改成了监听 ResizeObserver,这样可以解决真正展开、收起 Collapse 后误触发重新定位的bug

最终效果

小结

  1. 全屏拖拽应该是很常见的功能,社区里也有很多好的实现,但是有特殊位置要求时就需要有额外开发;
  2. 文中的工具函数结合react-rnd可以适配很多拖拽时有边界控制的场景,希望给大家带来一点帮助。
相关推荐
mCell4 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell5 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭5 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清5 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木5 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076605 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声5 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易5 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得06 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化
anOnion6 小时前
构建无障碍组件之Dialog Pattern
前端·html·交互设计