元素移动、拖拽、旋转

1,296 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

元素移动、拖拽、旋转

线上demo连接

效果gif图:

效果图.gif

功能描述

  • 可以随意移动、旋转、缩放
  • 可显示按钮点操作或隐藏按钮点直接操作元素边
  • 按shift等比例缩放

功能实现

一.元素结构

  • 最外层元素:1.拖动时的移动目标(用transform做移动性能比较好);2.旋转时的中心点定位
  • 第二次用着元素拉伸缩放和旋转的目标
  • 第三层:第一个是缩放和旋转等按钮的放置区;第二个内容放置区+内容反转区 image.png

二.移动

  • 在内容放置区元素上添加mousedown、mousemove、mouseup三个监听事件。分别对应元素开始移动、元素移动中、元素移动结束。
  • 在元素开始移动的时记下鼠标起始坐标,在移动中实时计算当前鼠标坐标减去起始坐标就得到元素需要移动的值。
<!-- 内容放置区+内容反转区 -->
<div 
    class="content" 
        :style="{
          transform: `scale(${isReverseX? -1:1}, ${isReverseY? -1:1})`
        }"
        @mousedown="moveHandleLocal('begin', $event)"
        @mousemove="moveHandleLocal('moving', $event)"
        @mouseup="moveHandleLocal('end', $event)"
    >
    <slot></slot>
</div>
export const isMoving = ref(false);

type MoveType = 'begin' | 'moving' | 'end'

/**
 * @description: 元素移动处理函数
 * @param {MoveType} moveType 移动的类型:'begin' | 'moving' | 'end'
 * @param {MouseEvent} e 鼠标事件参数 
 */
export function moveHandle(moveType: MoveType, e: MouseEvent) {
  const distributeObj: Record<MoveType, (e: MouseEvent) => void> = {
    'begin': beginMoveHandle,
    'moving': onMovingHandle,
    'end': endMoveHandle
  }

  distributeObj[moveType](e)
}

// 开始移动
let [mouseBeginX, mouseBeginY] = [0, 0];
let [originLeft, originTop] = [0, 0];
function beginMoveHandle(e: MouseEvent) {
  isMoving.value = true;
  [mouseBeginX, mouseBeginY] = [e.clientX, e.clientY];
  [originLeft, originTop] = [translateBoxLeft.value, translateBoxTop.value];
}

// 移动中
function onMovingHandle(e: MouseEvent) {
  if(!isMoving.value) return;
  moveTo(e)
}

// 移动结束
function endMoveHandle(e: MouseEvent) {
  if(!isMoving.value) return;
  isMoving.value = false;
  moveTo(e)
}

function moveTo(e: MouseEvent) {
  const moveX = e.clientX - mouseBeginX;
  const moveY = e.clientY - mouseBeginY;
  [translateBoxLeft.value, translateBoxTop.value] = [originLeft + moveX, originTop + moveY];
}
  • 但这里会遇到一个问题,如果鼠标移动过快,一下子移除了元素的范围。那么元素的mousemove就不再触发,移动就会中断。所以拖动时需要需要增加一个宽高都为100%的元素浮在最上层来做鼠标拖动事件的载体。

image.png

三.旋转

  • 给旋转按钮添加mousedown、drag、mouseup、dragend事件监听,对应旋转开始、旋转中、旋转结束。因为我们的旋转按钮是一张图片,所以可以借助图片的拖拽事件监听,拖拽事件还有一个好处就是不会发生鼠标拖动太快移出元素之外的问题。
  • 开始旋转时几下当前鼠标的坐标、元素中心点坐标、元素原来的旋转角度
  • 鼠标的以浏览器页面的左上角为坐标起点。所以为了保证元素中心点的坐标和鼠标坐标处于统一坐标系。元素中心点坐标需要用getBoundingClientRec这个api来计算。而一个元素旋转之前和之后通过getBoundingClientRec拿到的值是不一样的,所以为了保证中心点坐标的正确性。需要在最外层放一个不会旋转只会移动的div作为旋转时的中心点定位。而里面一层div再拿来做旋转的目标对象(参考元素结构图)
  • 旋转时根据当前鼠标做标、中心点坐标、起始点坐标三个点算出元素要旋转的角度
/**
 * @description: 求一个点伸出的两条线的夹角(带正负)
 * @param {*} centerA 中心点坐标
 * @param {*} pointB 旋转起点末端坐标
 * @param {*} pointC 旋转终点末端坐标
 * @param {*} isRadian 是否要弧度值(默认拿角度值)
 * @return {*} 返回旋转角度(带正负)
 */
 function getAngleOfThreePoint(
  centerA: [number, number], 
  pointB: [number, number], 
  pointC: [number, number],
  isRadian?: boolean
): number  {
  // 0.根据向量法求出旋转方向
  let AB = [0, 0];
  let AC = [0, 0];
  AB[0] = (pointB[0] - centerA[0]);
  AB[1] = (pointB[1] - centerA[1]);
  AC[0] = (pointC[0] - centerA[0]);
  AC[1] = (pointC[1] - centerA[1]); // 分别求出AB,AC的向量坐标表示
  let direct = (AB[0] * AC[1]) - (AB[1] * AC[0]); // AB与AC叉乘求出逆时针还是顺时针旋转

  // 1.先算出三条边的长度
  // 2.利用两点坐标求直线公式算出AB,AC,BC线段的长度
  let lengthAB = Math.sqrt(Math.pow(centerA[0] - pointB[0], 2) + Math.pow(centerA[1] - pointB[1], 2));
  let lengthAC = Math.sqrt(Math.pow(centerA[0] - pointC[0], 2) + Math.pow(centerA[1] - pointC[1], 2));
  let lengthBC = Math.sqrt(Math.pow(pointB[0] - pointC[0], 2) + Math.pow(pointB[1] - pointC[1], 2));

  // 3.已知三角形的三边长,求cos值的公式:cos A=(b²+c²-a²)/2bc
  let cosA = (Math.pow(lengthAB, 2) + Math.pow(lengthAC, 2) - Math.pow(lengthBC, 2)) / (2 * lengthAB * lengthAC); //   余弦定理求出旋转角

  // 4.在根据公式,转换成度数
  const angle = isRadian? Math.acos(cosA) : Math.acos(cosA) * 180 / Math.PI;
  
  return direct < 0? -angle:angle;
}
<!-- 旋转按钮 -->
        <div 
          v-if="!props.closeRotate" 
          class="rotateBtn"
          @mousedown="rotateHandleLocal('begin', $event)"
          @drag="rotateHandleLocal('moving', $event)"
          @dragend="rotateHandleLocal('end', $event)"
        ><img :src="props.hideControlBtn? opacityZeroImg:rotateBtnImg" ></div>
      </div>
export const isRotating = ref(false);

/**
 * @description: 元素移动处理函数
 * @param {MoveType} rotateType 移动的类型:'begin' | 'moving' | 'end'
 * @param {MouseEvent} e 鼠标事件参数 
 * @return {*}
 */
export function rotateHandle(rotateType: MoveType, e: MouseEvent) {
  const distributeObj: Record<MoveType, (e: MouseEvent) => void> = {
    'begin': beginRotateHandle,
    'moving': rotatingHandle,
    'end': endRotateHandle
  }

  distributeObj[rotateType](e)
}

let [mouseBeginX, mouseBeginY] = [0, 0];
let [centerX, centerY] = [0, 0];
let originRotate = 0;
function beginRotateHandle(e: MouseEvent) {
  isRotating.value = true;
  [mouseBeginX, mouseBeginY] = [e.clientX, e.clientY];
  const {left, top} = document.querySelector('.translateBox')!.getBoundingClientRect();
  [centerX, centerY] = [left + translateBoxWidth.value/2, top+translateBoxHeight.value/2];
  originRotate = translateBoxRotate.value;
}

function rotatingHandle(e: MouseEvent) {
  if(!isRotating.value) return;
  rotateTo(e)
}

// 移动结束
function endRotateHandle(e: MouseEvent) {
  if(!isRotating.value) return;
  isRotating.value = false;
  rotateTo(e);
}

function rotateTo(e: MouseEvent) {
  const angle = Util.getAngleOfThreePoint([centerX, centerY], [mouseBeginX, mouseBeginY], [e.clientX, e.clientY])
  translateBoxRotate.value = originRotate + angle;
}

四.缩放

  • 给缩放按钮增加相应的缩放开始、进行中、结束事件的监听。
<!-- 缩放按钮 -->
        <template v-if="!props.closeSpread" >
          <div 
            v-for="item in btnData"
            :class="`btn spread${item.name[0].toUpperCase()+item.name.substring(1)}Btn`"
            :style="{
              cursor: item.cursor,
              bottom: item.bottom,
              top: item.top,
              left: item.left,
              right: item.right,
              width: item.width ?? '23px',
              height: item.height ?? '23px',
            }"
            @mousedown="spreadHandLocal(item.name, 'begin', $event)"
            @drag="spreadHandLocal(item.name, 'moving', $event)"
            @dragend="spreadHandLocal(item.name, 'end', $event)"
          >
            <img :src="props.hideControlBtn? opacityZeroImg:spreadBtnImg" :style="{
              width: item.width? '100%':'23px',
              height: item.height? '100%':'23px',
            }" >
          </div>
        </template>

1.向上下左右四个方向缩放时

image.png

  • 以此图为例。假设现在元素已顺时针旋转*度:Q_angle。这时用户按住O点(A和B中间的那个拖拽按钮)。向右下方拖拽到点C。这时我们希望右上角的那条边不动。左下角的边跟着移动CB这个平面
  1. 根据C点(当前鼠标位置)和A点(图形中心点)坐标求出线段AC的长度:AC_len
/**
 * @description: 根据坐标计算坐标长度
 * @param {array} shape 坐标
 * @return { number } 长度
 */
function GetWebMercatorLen(shape: [number, number] []): number {
  let resultLen = 0;
  for(let i = 0; i < shape.length -1; i++) {
    const itemLen = Math.sqrt(Math.pow(shape[i+1][0] - shape[i][0], 2) + Math.pow(shape[i+1][1] - shape[i][1], 2));
    resultLen += itemLen
  }
  return resultLen
}
  1. 根据C点、A点、O点(拖拽起点位置坐标)坐标求出线段CA和BA的夹角:CAB_angle
  2. 求出AB的长度:AB_len = AC_len*Math.cos(CAB_angle)
  3. 根据A、O点坐标求出AO的长度:AO_len
  4. 元素宽度增量:widthAddValue=AB_len - AO_len
  5. 我们把元素的宽度改变可以修改width或scale。scale的性能比width好。但如果内容是文本,并且不希望文本跟着放到,那就用width。
  6. 把元素的with增加之后原本的元素中点A就移动到了A1。但我们希望右上角的边不动的并且上下两条线也不上下移动的话。就需要维持中心点在AB这条线上运动。所以我需要通过修改元素定位,把A1挪到A2。A2的坐标是A1点坐标沿A点旋转Q_angle度后的坐标
/**
 * @description: 把坐标旋转固定角度后生成新坐标
 */
 function createLatLngOfRotate (param: {
  latLng: [number, number],
  center: [number, number],
  rotateNum: number
}
): [number, number] {
// 1.求点到中心点的直线距离
const r = Math.sqrt(Math.pow(param.latLng[0] - param.center[0], 2) +  Math.pow(param.latLng[1] - param.center[1], 2))
// 2.求点到x之间的角度
let rotateValue = Math.atan2(param.latLng[1]-param.center[1], param.latLng[0]-param.center[0])/(2*Math.acos(-1))*360;
// 3.减去旋转的角度 再整体转为弧度
rotateValue = (rotateValue - param.rotateNum)*Math.PI/180;
// 4.根据中心的坐标、弧度、半径计算出新的坐标
return [param.center[0]+r*Math.cos(rotateValue), param.center[1]+r*Math.sin(rotateValue)]
}
  1. 求出A2和A1自己的坐标差x和y;然后把元素加上x和y的偏移值。这样元素的中心点就挪到了AB这条线上了。
  2. 其他三个方向类似

2.向四个斜角的方向缩放

image.png

  • 以此图为例(看右下角这块就行,其他方向的标注是博主计算其他角时用的)。假设现在元素已顺时针旋转*度:Q_angle。这时用户按住B点。向右下方拖拽到点C。这时我们希望右上角的那个点不动。左下角的点B跟着移动C的这个位置
  1. 点A坐标:(根据getBoundingClientRec和元素宽高的各一半计算)
  2. 点D坐标(最右边那个边的中点):根据点A和元素宽度的一半计算出
  3. 点D1坐标:把点D旋转Q_angle度后得到;
  4. 点B坐标:根据点A和元素宽高的一半计算出;
  5. 点C坐标:当前鼠标按下位置的坐标
  6. 角CBD1的值:CBD1_angle;
  7. 角EBC的值:EBC_angle = 180-CBD1_angle;
  8. 线段BC的长度:BC_len = B、C点坐标
  9. x, y(BC长度在为宽度和高度方向的偏移量): x=BC_len * Math.sin(EBC_angle); y=BC_len * Math.cos(EBC_angle)
  10. 把元素的宽高加上x,y的值后得到下图(图上的各个点已重新标注。和前一个图的标注点不一致。老规矩看右下角就行)

image.png

  1. 元素宽高变化后。中心点移动到了O1点。D点移到了M3点。P点的是鼠标当前位置点(对应前一个图的C点)。这时我们希望把图片的M3点移动到P点
  2. 点O1坐标:旧的中心点坐标加上x,y的一半
  3. 点M2坐标:O1坐标+元素宽高的一半
  4. 点M3坐标:M2坐标旋转Q_angle度
  5. offeSetX,offeSetY(元素需要偏移的值):P点和M3点的坐标差
  6. 元素定位加上/减去offeSetX,offeSetY

五.按住shift键正比例缩放

  • 上下左右四个方向缩放时。在原来的基础上,只需要加上另一个方向的等比例缩放,并网该方向缩回一半的地位就行
  • 斜角缩放需要把鼠标的当前位置投影到元素的对角线上。让元素保持沿着对角线缩放

代码过多,放到Gitee仓库上自行下载:下载链接

实战效果

本博主弄这个主要是用来对城市管网拓扑 进行 批量变换操作。灵感来源于图片编辑(旋转、缩放、移动、裁剪)

111.gif