Skip to content

拖拽释放算法

拖拽释放算法

统计信息:字数 10856 阅读22分钟

  1. 原理是什么
  2. 原生的JS怎么实现
  3. React-dnd 怎么实现

拖拽原理

  1. 设置某个DOM可拖拽,绑定拖拽事件的回调函数
  2. 设置某个DOM可释放,绑定释放事件的回调函数
  3. 开始拖拽,把相关的数据存放到拖动的对象上
  4. 拖动到可释放DOM上,释放,释放DOM的回调函数中获取数据,进行操作

样式

  • 对于拖拽的DOM事件:拖拽开始;拖拽中;拖拽结束(不同的样式)
  • 对于释放的DOM事件:可以释放;释放后(需要不同的样式)

React-dnd 基本实现

这是项目中 React-dnd 的使用方法,使用 2 版本。最新的 react-dnd 使用了 hook 和 typescript,使用 8 版本,所以下面的使用,和最新官方文档不同。

  1. 设置全部可拖拽可释放的DOM外层,使用 DragDropContext 包裹一层
  2. 把可以拖拽的部分用 DragSource 包裹
  3. 把可以释放的部分用 DropTarget 包裹

1 整体拖拽层

// 1 设置全部可拖拽可释放的DOM外层,使用 DragDropContext 包裹一层
import HTML5Backend from 'react-dnd-html5-backend';
import { DragDropContext } from 'react-dnd';

class App { ... }
export default DragDropContext(HTML5Backend)(App);

2 可以拖拽的部分

// 2 把可以拖拽的部分用 DragSource 包裹
import { DragSource } from 'react-dnd';

class MyComponent { ... }
export default DragSource(type, spec, collect)(MyComponent);

3 可以释放的部分

// 3 把可以释放的部分用 DropTarget 包裹
import { DropTarget } from 'react-dnd';

class MyComponent2 { ... }
export default DropTarget(types, spec, collect)(MyComponent2);

原生的JS

这个参考目录树的代码,自己手写一次

tree-view.js

// 支持拖拽的树节点
class TreeView extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      isTreeViewDropTipShow: false,
    };

    // 根据用户的权限判断是否可以拖拽(读写权限,或者自定义权限满足)
    const { userPerm } = props;
    this.canDrop = userPerm === 'rw';
    const { isCustomPermission, customPermission } = getUserPermission(userPerm);
    if (isCustomPermission) {
      const { modify } = customPermission.permission;
      this.canDrop = modify;
    }
  }

  // 移动项目的回调函数
  onItemMove = (repo, dirent, selectedPath, currentPath) => {
    this.props.onItemMove(repo, dirent, selectedPath, currentPath);
  }

  // 节点开始拖拽的函数(把数据存放在 dataTransfer 对象中)
  onNodeDragStart = (e, node) => {
    // 包括当前节点,当前节点路径,父节点的路径
    let dragStartNodeData = {
      nodeDirent: node.object,
      nodeParentPath: node.parentNode.path,
      nodeRootPath: node.path,
    };
    dragStartNodeData = JSON.stringify(dragStartNodeData);
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('application/drag-item-info', dragStartNodeData);
  }

  // 当节点拖拽进入的回调函数
  onNodeDragEnter = (e, node) => {
    // 如果不支持拖拽,返回
    if (!this.canDrop) {
      return false;
    }
    e.persist();
    // 如果支持拖拽,设置状态
    if (e.target.className === 'tree-view tree ') {
      this.setState({
        isTreeViewDropTipShow: true,
      });
    }
  }

  // 节点拖拽移动(设置拖拽对象的属性是 move)
  onNodeDragMove = (e) => {
    if (!this.canDrop) {
      return false;
    }
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
  }

  // 当节点拖拽离开(改变状态)
  onNodeDragLeave = (e, node) => {
    if (!this.canDrop) {
      return false;
    }
    if (e.target.className === 'tree-view tree tree-view-drop') {
      this.setState({
        isTreeViewDropTipShow: false,
      });
    }
  }

  // 当拖拽释放
  onNodeDrop = (e, node) => {
    if (!this.canDrop) {
      return false;
    }
    // uploaded files
    if (e.dataTransfer.files.length) {
      return;
    }

    // 从拖拽对象中,获取节点属性,转换成对象
    let dragStartNodeData = e.dataTransfer.getData('application/drag-item-info');
    dragStartNodeData = JSON.parse(dragStartNodeData);

    // 获取拖动的目录对象,父节点路径,子节点路径
    let { nodeDirent, nodeParentPath, nodeRootPath } = dragStartNodeData;
    let dropNodeData = node;

    // 分别处理不同的拖拽情况

    //move items 移动多个目录
    if (Array.isArray(dragStartNodeData)) {
      if (!dropNodeData) { //move items to root
        if (dragStartNodeData[0].nodeParentPath === '/') {
          this.setState({isTreeViewDropTipShow: false});
          return;
        }
        this.props.onItemsMove(this.props.currentRepoInfo, '/');
        this.setState({isTreeViewDropTipShow: false});
        return;
      }
      this.onMoveItems(dragStartNodeData, dropNodeData, this.props.currentRepoInfo, dropNodeData.path);
      return;
    }

    if (!dropNodeData) {
      if (nodeParentPath === '/') {
        this.setState({isTreeViewDropTipShow: false});
        return;
      }
      this.onItemMove(this.props.currentRepoInfo, nodeDirent, '/', nodeParentPath);
      this.setState({isTreeViewDropTipShow: false});
      return;
    }

    if (dropNodeData.object.type !== 'dir') {
      return;
    }

    if (nodeParentPath === dropNodeData.path) {
      return;
    }

    // copy the dirent to itself. eg: A/B -> A/B
    if (nodeParentPath === dropNodeData.parentNode.path) {
      if (dropNodeData.object.name === nodeDirent.name) {
        return;
      }
    }

    // copy the dirent to it's child. eg: A/B -> A/B/C
    if (dropNodeData.object.type === 'dir' && nodeDirent.type === 'dir') {
      if (dropNodeData.parentNode.path !== nodeParentPath) {
        let paths = Utils.getPaths(dropNodeData.path);
        if (paths.includes(nodeRootPath)) {
          return;
        }
      }
    }

    this.onItemMove(this.props.currentRepoInfo, nodeDirent, dropNodeData.path, nodeParentPath);
  }

  // 移动多个目录
  onMoveItems = (dragStartNodeData, dropNodeData, destRepo, destDirentPath) => {
    let direntPaths = [];
    let paths = Utils.getPaths(destDirentPath);
    dragStartNodeData.forEach(dirent => {
      let path = dirent.nodeRootPath;
      direntPaths.push(path);
    });

    if (dropNodeData.object.type !== 'dir') {
      return;
    }

    // move dirents to one of them. eg: A/B, A/C -> A/B
    if (direntPaths.some(direntPath => { return direntPath === destDirentPath;})) {
      return;
    }

    // move dirents to current path
    if (dragStartNodeData[0].nodeParentPath && dragStartNodeData[0].nodeParentPath === dropNodeData.path ) {
      return;
    }

    // move dirents to one of their child. eg: A/B, A/D -> A/B/C
    let isChildPath = direntPaths.some(direntPath => {
      return paths.includes(direntPath);
    });
    if (isChildPath) {
      return;
    }

    this.props.onItemsMove(destRepo, destDirentPath);
  }

  // 点击目录项,需要隐藏菜单
  onMenuItemClick = (operation, node) => {
    this.props.onMenuItemClick(operation, node);
    hideMenu();
  }

  onMouseDown = (event) => {
    event.stopPropagation();
    if (event.button === 2) {
      return;
    }
  }

  onContextMenu = (event) => {
    event.preventDefault();
    let currentRepoInfo = this.props.currentRepoInfo;
    if (currentRepoInfo.permission !== 'admin' && currentRepoInfo.permission !== 'rw') {
      return '';
    }
    this.handleContextClick(event);
  }

  // 点击内容的回调函数
  handleContextClick = (event, node) => {
    event.preventDefault();
    event.stopPropagation();

    if (!this.props.isNodeMenuShow) {
      return;
    }

    // 获取点击的位置
    let x = event.clientX || (event.touches && event.touches[0].pageX);
    let y = event.clientY || (event.touches && event.touches[0].pageY);
    if (this.props.posX) {
      x -= this.props.posX;
    }
    if (this.props.posY) {
      y -= this.props.posY;
    }


    // 关闭当前的菜单
    hideMenu();

    // 根据点击的位置,显示新的菜单
    let menuList = this.getMenuList(node);
    let showMenuConfig = {
      id: 'tree-node-contextmenu',
      position: { x, y },
      target: event.target,
      currentObject: node,
      menuList: menuList,
    };
    showMenu(showMenuConfig);
  }

  // 获取菜单项
  getMenuList = (node) => {
    let menuList = [];
    let { NEW_FOLDER, NEW_FILE, COPY, MOVE, RENAME, DELETE, OPEN_VIA_CLIENT } = TextTranslation;
    // 根据文件还是文件夹,显示不同的菜单
    if (node.object.type === 'dir') {
      menuList = [NEW_FOLDER, NEW_FILE, COPY, MOVE, RENAME, DELETE];
    } else {
      menuList = [RENAME, DELETE, COPY, MOVE, OPEN_VIA_CLIENT];
    }

    const { userPerm } = this.props;
    const { isCustomPermission, customPermission } = Utils.getUserPermission(userPerm);
    if (!isCustomPermission) {
      return menuList;
    }

    // 根据不同的权限,增加不同的菜单等
    menuList = [];

    const { modify: canModify, delete: canDelete, copy: canCopy } = customPermission.permission;
    if (!node) {
      canModify && menuList.push(NEW_FOLDER, NEW_FILE);
      return menuList;
    }

    if (node.object.type === 'dir') { 
      canModify && menuList.push(NEW_FOLDER, NEW_FILE);
    }

    canCopy && menuList.push(COPY);
    canModify && menuList.push(MOVE, RENAME);
    canDelete && menuList.push(DELETE);

    if (node.object.type !== 'dir') { 
      menuList.push(OPEN_VIA_CLIENT);
    }

    return menuList;
  }

  render() {
    return (
      <div
        className={`tree-view tree ${(this.state.isTreeViewDropTipShow && this.canDrop) ? 'tree-view-drop' : ''}`}
        onDrop={this.onNodeDrop}
        onDragEnter={this.onNodeDragEnter}
        onDragLeave={this.onNodeDragLeave}
        onMouseDown={this.onMouseDown}
        onContextMenu={this.onContextMenu}
      >
        <TreeNodeView
          userPerm={this.props.userPerm}
          node={this.props.treeData.root}
          currentPath={this.props.currentPath}
          isNodeMenuShow={this.props.isNodeMenuShow}
          isItemFreezed={this.state.isItemFreezed}
          onNodeClick={this.props.onNodeClick}
          onMenuItemClick={this.props.onMenuItemClick}
          onNodeExpanded={this.props.onNodeExpanded}
          onNodeCollapse={this.props.onNodeCollapse}
          onNodeDragStart={this.onNodeDragStart}
          onNodeDragMove={this.onNodeDragMove}
          onNodeDrop={this.onNodeDrop}
          onNodeDragEnter={this.onNodeDragEnter}
          onNodeDragLeave={this.onNodeDragLeave}
          handleContextClick={this.handleContextClick}
        />
      </div>
    );
  }
}

TreeView.propTypes = propTypes;

export default TreeView;

Last update: November 9, 2024