You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React中实现DIV间交互式平滑连线的方案问询

要做类似Figma的交互式连线,核心是让用户通过「点击起始元素→拖拽→释放到目标元素」的流程完成连接,实时获取坐标驱动连线渲染。结合你现有的Connection组件,按以下步骤实现即可:

1. 设计交互式连线的核心流程

  • 触发创建:给所有可连接元素(Quiz A的选项、Quiz B)绑定mousedown(移动端加touchstart)事件,记录起始元素的中心坐标,标记「正在创建连线」状态。
  • 拖拽跟随:监听全局mousemove事件,实时更新临时连线的终点为当前鼠标位置,让连线随鼠标移动。
  • 完成/取消:监听全局mouseup事件,检测鼠标是否落在另一个可连接元素上——若是则保存完整连线;若否则取消创建。

2. 实时获取元素/鼠标的坐标

用getBoundingClientRect()获取DOM元素的视口位置,结合滚动偏移计算绝对坐标,取元素中心作为连线起止点更自然:

// 获取元素中心的绝对坐标
const getElementCenter = (element) => {
  const rect = element.getBoundingClientRect();
  return {
    x: rect.left + rect.width / 2 + window.scrollX,
    y: rect.top + rect.height / 2 + window.scrollY
  };
};

// 获取鼠标的绝对坐标
const getMousePosition = (e) => ({
  x: e.clientX + window.scrollX,
  y: e.clientY + window.scrollY
});

3. 结合现有Connection组件实现完整功能

以下是整合拖拽逻辑的React父组件示例:

'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Connection } from './Connection';

// 连线类型定义
type TConnection = {
  start: { x: number; y: number };
  end: { x: number; y: number };
};

// 可连接元素组件
const ConnectableElement = ({ id, children, onStartDrag }: {
  id: string;
  children: React.ReactNode;
  onStartDrag: (e: React.MouseEvent<HTMLDivElement>) => void;
}) => {
  return (
    <div 
      data-connectable-id={id} 
      className="connectable-element"
      onMouseDown={onStartDrag}
    >
      {children}
    </div>
  );
};

export const QuizConnector = () => {
  const [connections, setConnections] = useState<TConnection[]>([]);
  const [tempConnection, setTempConnection] = useState<TConnection | null>(null);
  const isDraggingRef = useRef(false);
  const startElementRef = useRef<HTMLElement | null>(null);

  // 绑定全局鼠标事件
  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (!isDraggingRef.current || !startElementRef.current) return;
      const start = getElementCenter(startElementRef.current);
      const end = getMousePosition(e);
      setTempConnection({ start, end });
    };

    const handleMouseUp = (e: MouseEvent) => {
      if (!isDraggingRef.current) return;
      isDraggingRef.current = false;

      // 检测是否命中目标可连接元素
      const targetElement = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;
      const connectableTarget = targetElement?.closest('[data-connectable-id]');
      
      if (connectableTarget && connectableTarget !== startElementRef.current) {
        const start = getElementCenter(startElementRef.current!);
        const end = getElementCenter(connectableTarget);
        setConnections(prev => [...prev, { start, end }]);
      }

      setTempConnection(null);
      startElementRef.current = null;
    };

    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('mouseup', handleMouseUp);
    };
  }, []);

  // 处理起始元素的拖拽触发
  const handleStartDrag = (e: React.MouseEvent<HTMLDivElement>) => {
    isDraggingRef.current = true;
    startElementRef.current = e.currentTarget;
  };

  // 工具函数:获取元素中心坐标
  const getElementCenter = (element: HTMLElement) => {
    const rect = element.getBoundingClientRect();
    return {
      x: rect.left + rect.width / 2 + window.scrollX,
      y: rect.top + rect.height / 2 + window.scrollY
    };
  };

  // 工具函数:获取鼠标绝对位置
  const getMousePosition = (e: MouseEvent) => ({
    x: e.clientX + window.scrollX,
    y: e.clientY + window.scrollY
  });

  return (
    <div className="quiz-container" style={{ position: 'relative', minHeight: '500px' }}>
      {/* 连线层:放在元素下方,避免遮挡交互 */}
      <div className="connections-layer" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
        {connections.map((conn, idx) => (
          <Connection key={idx} start={conn.start} end={conn.end} />
        ))}
        {tempConnection && (
          <Connection start={tempConnection.start} end={tempConnection.end} />
        )}
      </div>

      {/* Quiz A 选项区 */}
      <div className="quiz-a" style={{ position: 'absolute', top: '20px', left: '20px' }}>
        <h3>Quiz A</h3>
        <ConnectableElement id="opt1" onStartDrag={handleStartDrag}>
          Option 1
        </ConnectableElement>
        <ConnectableElement id="opt2" onStartDrag={handleStartDrag}>
          Option 2
        </ConnectableElement>
      </div>

      {/* Quiz B 目标区 */}
      <div className="quiz-b" style={{ position: 'absolute', top: '20px', right: '20px' }}>
        <h3>Quiz B</h3>
        <ConnectableElement id="quiz-b" onStartDrag={handleStartDrag}>
          点击连接到我
        </ConnectableElement>
      </div>
    </div>
  );
};

4. 细节优化建议

  • 层级控制:给连线层设置pointerEvents: none,避免遮挡可连接元素的点击事件。
  • 样式区分:给临时连线加虚线样式(比如stroke-dasharray: 5,5),区分正在创建和已完成的连线。
  • 移动端适配:补充touchstart/touchmove/touchend事件处理,通过e.changedTouches[0]获取触摸坐标。
  • 滚动适配:监听scroll事件,用防抖函数重新计算所有连线的坐标,避免滚动时连线偏移。
  • 删除功能:给每条连线添加点击事件,点击时从connections数组中移除对应项。

内容的提问来源于stack exchange,提问作者Lvasche

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 15:00:03