手写组件操作:拖拽移动+调整大小+旋转

1,511 阅读5分钟

拖拽移动,调整大小,旋转是低代码常用的组件操作,之前的项目经常用到,好记性不如烂笔头,我总结归纳了一下!

3333.gif

1. 监听鼠标事件

  • 布局
<div class="cardContainer" id="cardContainer">
<!--单个操作组件-->
      <div class="card-elmt gold" style="left: 100px; top: 100px; width: 100px; height: 100px">
      <!--旋转手柄-->
        <div class="rotation"></div>
        <!--内容-->
        <div class="body">AAAAA</div>
      </div>

       <!-- ...-->
    </div>
  • 注册监听事件

这里采用事件委托,父级容器监听,减少单个组件注册事件监听。

export type CardActionType = {
  container: HTMLElement;
  moveClass: string;
  resizeClass: string;
  rotateClass: string;
  moveCallback?: (p: MoveCallbackType) => void;
  resizeCallback?: (p: ResizeCallbackType) => void;
  rotateCallback?: (p: RotateCallbackType) => void;
};
export function useCardAction(option: CardActionType) {
//...
const init = () => {
    //注册鼠标监听
    option.container.addEventListener('pointerdown', onStart);
    option.container.addEventListener('pointermove', onMove);
    option.container.addEventListener('pointerup', onEnd);
  };

  const dispose = () => {
    //移除鼠标监听
    option.container.removeEventListener('pointerdown', onStart);
    option.container.removeEventListener('pointermove', onMove);
    option.container.removeEventListener('pointerup', onEnd);
  };

  return { init, dispose };
  }

const cardAction = useCardAction({
  container: document.getElementById('cardContainer')!,
  rotateClass: 'rotation',
  moveClass: 'card-elmt',
  resizeClass: 'card-elmt'
});
cardAction.init();

2.组件拖拽移动操作

  • 按下鼠标时的操作
const move: MoveActionType = {
    //当前鼠标位置
    startX: 0,
    startY: 0,
    //操作的相对移动位置
    x: 0,
    y: 0,
    //原始位置
    left: 0,
    top: 0,
    //最终位置
    endLeft: 0,
    endTop: 0,
    //是否可移动
    enable: false
  };
 //初始化操作参数
  const startInit = (move: MoveActionType, ev: PointerEvent, target: HTMLElement) => {
    move.target = target;
    move.enable = true;
    move.startX = ev.clientX;
    move.startY = ev.clientY;
    move.x = 0;
    move.y = 0;
    move.left = target.offsetLeft;
    move.top = target.offsetTop;
    move.endLeft = target.offsetLeft;
    move.endTop = target.offsetTop;
  };
  //pointerdown动作
  const onStart = (ev: PointerEvent) => {
     const target = ev.target as HTMLElement;
     //包含moveClass类目的组件
     if (target.classList.contains(option.moveClass)) {
        console.log('move');
        document.body.style.cursor = 'move';
        target.style.cursor = 'move';
        startInit(move, target, ev);
      }
  }
  • 移动鼠标时的操作move.xmove.y不停叠加前后位置的偏移值,就是相对于原始位置的移动距离。
const onMove = (ev: PointerEvent) => {
 if (move.target && move.enable) {
     //操作的相对移动位置
      move.x += ev.clientX - move.startX;
      move.y += ev.clientY - move.startY;
      //重置开始位置
      move.startX = ev.clientX;
      move.startY = ev.clientY;
      //新的位置
      move.endLeft = move.left + move.x;
      move.endTop = move.top + move.y;
      //设置组件移动后的位置
      move.target.style.left = `${move.endLeft}px`;
      move.target.style.top = `${move.endTop}px`;
    }else {
    //悬浮在moveClass类目的组件上则改变鼠标样式
    if (  target.classList.contains(option.moveClass)) {  
              document.body.style.cursor = 'move';
              target.style.cursor = 'move';
            } else {      
              document.body.style.cursor = 'default';
            }
    }
}
  • 放开鼠标结束操作
  const onEnd = () => {
  //鼠标样式回归默认
  document.body.style.cursor = 'default';
  if (move.enable && move.target) {
      move.target.style.cursor = 'default';
      //移动操作后回调
     option.moveCallback && option.moveCallback({ left: move.endLeft, top: move.endTop, target: move.target });
    }
    //关闭移动操作
    move.enable = false;
  }

20241125_175204.gif

3.组件调整大小操作

目前许多封装的拖拽调整大小组件都会为边增加八个点作为操作手柄,我这里采用边缘检测来确认具体操作的是哪个边,不需添加操作手柄。

image.png

  • 边缘检测
//在边缘的[-5px,+5px]范围
const minEdge = 5;
//父级容器偏移量
let offsetX = option.container.offsetLeft;
  let offsetY = option.container.offsetTop;
  //判断是否为resize操作
  const resizeCursor = (ev: PointerEvent, isDown?: boolean) => {
    const target = ev.target as HTMLElement;
    let c = '';
    //包含resizeClass类目的组件
    if (target.classList.contains(option.resizeClass)) {
      let xSide = '';
      let ySide = '';
      //判断鼠标位置是否在左右边附近
      if (Math.abs(ev.clientX - offsetX - target.offsetLeft) <= minEdge) {
        xSide = 'left';
      } else if (
        Math.abs(ev.clientX - offsetX - (target.offsetLeft + target.offsetWidth)) <= minEdge
      ) {
        xSide = 'right';
      }
      //判断鼠标位置是否在上下边附近
      if (Math.abs(ev.clientY - offsetY - target.offsetTop) <= minEdge) {
        ySide = 'top';
      } else if (
        Math.abs(ev.clientY - offsetY - (target.offsetTop + target.offsetHeight)) <= minEdge
      ) {
        ySide = 'bottom';
      }
      //设置鼠标resize样式
      if (xSide && ySide) {
        if ((xSide == 'left' && ySide == 'top') || (xSide == 'right' && ySide == 'bottom')) {
          c = 'nwse-resize';
        } else {
          c = 'nesw-resize';
        }
      } else if (ySide) {
        c = 'ns-resize';
      } else if (xSide) {
        c = 'ew-resize';
      }

      if (c) {
        document.body.style.cursor = c;
        target.style.cursor = c;
        if (isDown) {
          //鼠标按下,初始化操作参数
          console.log('resize');
          startInit(resize, ev, target);
          resize.side = xSide + ySide;
          resize.w = target.offsetWidth;
          resize.h = target.offsetHeight;
          resize.endW = target.offsetWidth;
          resize.endH = target.offsetHeight;
        }
      }
    }
    return c;
  };

注意: 因为组件的位置也是相对于父级容器的,而点击事件clientXclientY的坐标是相对于整个屏幕的,所以要减去父级容器相对于body的偏移量offsetXoffsetY才是相对于父级容器的坐标。

  • 按下鼠标操作
  const resize: ResizeActionType = {
    //当前鼠标位置
    startX: 0,
    startY: 0,
    //操作的相对移动位置
    x: 0,
    y: 0,
    //原始位置
    left: 0,
    top: 0,
    //原始宽高
    w: 0,
    h: 0,
    //最终位置
    endLeft: 0,
    endTop: 0,
    //最终宽高
    endW: 0,
    endH: 0,
    //当前操作的边
    side: '',
    //是否可调整大小
    enable: false
  };
 const onStart = (ev: PointerEvent) => {
    const target = ev.target as HTMLElement;
     const c = resizeCursor(ev, true);
     //resize操作优先于move操作
     if (!c && target.classList.contains(option.moveClass)) {
     //...
     }
    }

20241125_204234.gif

  • 移动操作,需要针对上下边和左右边分别处理:上边操作缘则移动位置和增减高度,下边操作只需增减高度,左边操作则移动位置和增减宽度,右边操作只需增减宽度
const onMove = (ev: PointerEvent) => {
if (resize.target && resize.enable) {
      //操作的相对移动位置
      resize.x += ev.clientX - resize.startX;
      resize.y += ev.clientY - resize.startY;

      //左边操作缘则移动位置和增减宽度,右边操作只需增减宽度
      if (resize.side.startsWith('left')) {
        resize.endLeft = ev.clientX - offsetX;
        resize.endW = resize.w - resize.x;
      } else if (resize.side.startsWith('right')) {
        resize.endW = resize.w + resize.x;
      }
      //上边操作缘则移动位置和增减高度,下边操作只需增减高度
      if (resize.side.endsWith('top')) {
        resize.endTop = ev.clientY - offsetY;
        resize.endH = resize.h - resize.y;
      } else if (resize.side.endsWith('bottom')) {
        resize.endH = resize.h + resize.y;
      }
      //更新组件位置和大小
      resize.target.style.left = resize.endLeft + 'px';
      resize.target.style.width = resize.endW + 'px';
      resize.target.style.top = resize.endTop + 'px';
      resize.target.style.height = resize.endH + 'px';
      //重置开始位置
      resize.startX = ev.clientX;
      resize.startY = ev.clientY;
    } 

}
  • 放开鼠标结束操作
  const onEnd = () => {
  //鼠标样式回归默认
  document.body.style.cursor = 'default';
if (resize.enable && resize.target) {
      resize.target.style.cursor = 'default';
      //调整大小后回调
      option.resizeCallback &&
        option.resizeCallback({
          left: resize.endLeft,
          top: resize.endTop,
          target: resize.target,
          width: resize.endW,
          height: resize.endH
        });
    }
    //关闭调整大小操作
    resize.enable = false;
    }

20241125_205054.gif

4.组件旋转操作

image.png

旋转角度的计算:

  • 以组件中心点作为原点center=[elmt.offsetLeft+elmt.offsetWidth*0.5,elmt.offsetTop+elmt.offsetHeight*0.5]

  • 当前鼠标的位置pos=[ev.clientX - offsetX,ev.clientY - offsetY],减去父级容器偏移量的理由同上。

  • 那么对应的x轴距离dx=center[0]-pos[0],y轴距离dy=center[1]-pos[1]

  • 假设x轴于鼠标点和原点连线的夹角为A,tanA=dy/dx,夹角A=atan(dy/dx),存在四个象限的与x轴正方形的夹角,所以夹角A=atan2(dy/dx)

  • 而没有旋转时旋转手柄在y轴正上方,与x轴正方向夹角为90度,需要减去90角度,即逆时针旋转九十度,将初始0度位置对上。

  • 按下鼠标操作

const radians2deg = (radians: number) => {
    return (radians * 180) / Math.PI;
  };
 const onStart = (ev: PointerEvent) => {
    const target = ev.target as HTMLElement; 
    //包含rotateClass的组件
    if (target.classList.contains(option.rotateClass)) {
      console.log('rotate');
      target.style.cursor = 'grab';
      document.body.style.cursor = 'grab';

      rotate.target = target;
      rotate.parent = target.parentElement as HTMLElement;
      rotate.parent.style.cursor = 'grab';
      //中心点位置
      rotate.center = [
        rotate.parent.offsetLeft + rotate.parent.offsetWidth * 0.5,
        rotate.parent.offsetTop + rotate.parent.offsetHeight * 0.5
      ];
      //计算当前旋转角度
      const dx = rotate.center[0] - (ev.clientX - offsetX);
      const dy = rotate.center[1] - (ev.clientY - offsetY);
      //atan2算出的角度偏差90度,需要减去
      const a = radians2deg(Math.atan2(dy, dx)) - 90;

      rotate.angle = a;
      //开启旋转操作
      rotate.enable = true;
    }
    }

注意

  1. 因为操作手柄在组件里面偏移出来的,所以真正操作的不是ev.target,而是target的父级元素。
  2. 另外避免事件冲突问题,旋转的优先级高于调整大小的,最终的操作优先级:旋转>调整大小>移动位置
  • 鼠标移动操作
  const onMove = (ev: PointerEvent) => {
    if (rotate.enable && rotate.parent) {
      //计算当前角度
      const dx = rotate.center[0] - (ev.clientX - offsetX);
      const dy = rotate.center[1] - (ev.clientY - offsetY);
      const a = radians2deg(Math.atan2(dy, dx)) - 90;
      rotate.angle = a;
    
      rotate.parent.style.transform = `rotate(${a % 360}deg)`;
    }
    }
  • 放开鼠标结束操作
const onEnd = () => {
    //鼠标样式回归默认
    document.body.style.cursor = 'default';
    if (rotate.enable && rotate.target && rotate.parent) {
      rotate.parent.style.cursor = 'default';
      rotate.target.style.cursor = 'default';
      //旋转后回调
      option.rotateCallback &&
        option.rotateCallback({ angle: rotate.angle, target: rotate.parent });
    }
    //关闭旋转操作
    rotate.enable = false;

20241125_213741.gif

总结

组件操作封装成了一个function,不局限于框架,浏览器环境基本都可适用,可能需要小小调整一下。 如果在react和vue中使用,不建议通过ref,state等赋值更新,操作太快,赋值太频繁可能会出现页面渲染更新卡顿跟不上操作的情况,建议直接修改DOM的Style样式属性,这样效果更好,更高效,可以在操作后回调,将对应值更新同步即可。

Github地址

https://github.com/xiaolidan00/demo-vite-ts

参考