记录元素平移、旋转、缩放和镜像翻转(2)

接着前面的继续写,前面简单实现了元素的移动,接下来实现元素的旋转变换,加入旋转变换后,先前实现的部分逻辑也要跟着改变。

首先提取之前用到的部分常量,提取出来后方便后期维护修改

// 初始元素宽度,在外界没有传入宽度时使用
const INIT_WIDTH = 50;
// 同初始元素宽度
const INIT_HEIGHT = 50;
// 初始元素 x 坐标,在外界没有传入时使用
const INIT_X = 10;
// 同初始元素 x 坐标
const INIT_Y = 10;
// 初始元素色值,在外界没有传入时使用
const INIT_COLOR = '#CCCCCC';

因为要实现元素的旋转变换,鼠标必须要在元素旋转变换区域才响应这个操作,所以为元素添加一个响应状态,又因为要实现元素的旋转,所以还得添加一个旋转角度。

对于元素的状态,如果元素未响应,则为 null,其他则是对应状态的字符串,因为元素有多个状态,为了方便维护,单独抽离状态字符串常量和状态数组,之后对于元素的操作,如果状态为状态数组里面的状态才响应操作,其他不再响应操作。

因为元素状态提取,所以对于设置元素状态需要再进行判断,只有在我们定义的状态数组里面的值才允许设置

// 单独提取状态常量
// 元素移动状态
const STATUS_MOVE = 'move';
// 元素旋转变换状态
const STATUS_TRANSFORM = 'transform';
// 状态数组
const STATUS_ARRAY = [STATUS_MOVE, STATUS_TRANSFORM, null];
handleOptions(options = {}) {
  // 省略之前的代码...

  // 新增元素状态,用于判断鼠标点击的是元素的什么位置,
  // 好做出相应的操作
  _options.status = null;
  // 新增旋转角度
  _options.rotate = 0;

  return new Proxy(_options, {
    set(target, property, value, receiver) {
      // 省略之前的代码...

      if (
        property === 'status' &&
        STATUS_ARRAY.indexOf(value) === -1
      ) {
        throw new Error('元素状态设置错误:' + value);
      }

      // 省略之前的代码...
    }
  });
}

因为加入了旋转,所以计算顶点坐标得重新考虑旋转角度了,计算规则大致相同,所以抽离计算函数

/**
 * 新增计算变换后的元素顶点坐标
 * 因为要加入旋转,所以单独提出来
 *
 * @param {Number} x 元素 x 坐标
 * @param {Number} y 元素 y 坐标
 * @param {Number} centerX 元素中心点 x 坐标
 * @param {Number} centerY 元素中心点 y 坐标
 * @param {Number} degrees 元素旋转角度
 * @return 返回计算好的顶点坐标数组
 * @description
 */
  _rotatePoint(x, y, centerX, centerY, degrees) {
  let _x = (x - centerX) *
    Math.cos(degrees * Math.PI / 180) -
    (y - centerY) *
    Math.sin(degrees * Math.PI / 180) +
    centerX;
  let _y = (x - centerX) *
    Math.sin(degrees * Math.PI / 180) +
    (y - centerY) *
    Math.cos(degrees * Math.PI / 180) +
    centerY;
  return [_x, _y];
}

改造计算元素顶点函数

/**
 * 重新计算当前元素的顶点坐标
 */
rotateSquare() {
  const currItem = this.currItem;

  if (!currItem) return;

  // 因为加入旋转,所以更改顶点计算方式
  currItem.square = [
    this._rotatePoint(
      currItem.x,
      currItem.y,
      currItem.centerX,
      currItem.centerY,
      currItem.rotate
    ),
    this._rotatePoint(
      currItem.x + currItem.width,
      currItem.y,
      currItem.centerX,
      currItem.centerY,
      currItem.rotate
    ),
    this._rotatePoint(
      currItem.x + currItem.width,
      currItem.y + currItem.height,
      currItem.centerX,
      currItem.centerY,
      currItem.rotate
    ),
    this._rotatePoint(
      currItem.x,
      currItem.y + currItem.height,
      currItem.centerX,
      currItem.centerY,
      currItem.rotate
    )
  ];
}

因为加入了元素的状态,所以鼠标点击的时候,我们必须得将元素状态设置,只要是在对应区域,那就设置对应的状态,之前只是判断点击元素是否在元素内部,现在元素内部需要划分部分区域,鼠标在这些区域响应正确的操作,所以我们得新增点击点位置判断函数,这里要实现的是元素的旋转变换,这个响应区域为元素的右下角,只要点击的坐标在此处,就响应变换操作,这样 render 函数也要进行改造

// 变换区域宽高常量
const TRAN_W = 20;
const TRAN_H = 20;

/**
 * 渲染
 */
render() {
  let str = '';

  this.elementArray.forEach(item => {
    str += `
      <div style="position:absolute;top:0;left:0;width:${item.width}px;height:${item.height}px;background-color:${item.backgroundColor};transform:translateX(${item.x}px) translateY(${item.y}px) translateZ(0px) rotate(${item.rotate}deg);">
        ${
          // 新增旋转区域,这里只是简单的实现,后期更改
          this.currItem && this.currItem.id === item.id ?
            `<div style="position:absolute;bottom:-${TRAN_H / 2}px;right:-${TRAN_W / 2}px;width:${TRAN_W}px;height:${TRAN_H}px;border:1px solid #666;font-size:12px;text-align:center;line-height:${TRAN_H}px;border-radius:50%;">旋</div>` :
            ''
        }
      </div>
    `;
  });

  this.elementContainer.innerHTML = str;
}

/** 
 * 新增元素点击区域判断,对于不同的点击区响应不同的操作方法
 * 为什么要新增这个函数?
 *   因为常规的实现这些元素的操作,元素内部响应移动操作,
 *   对于旋转等其他操作,一般就是元素的另外特定的区域,
 *   当鼠标在这些特定的区域,为了响应对应的操作,
 *   所以抽离成单独的函数
 * @param {Number} x 点击的 x 坐标
 * @param {Number} y 点击的 y 坐标
 * @param {*} item 要判断的元素
 * @return 不在元素任何区域内返回 false,在区域内返回相应的操作字符串
 */
isEleClickZone(x, y, item) {
  // 判断是否在旋转区域
  // 默认定义的区域在右下角
  let tranPosition = this._rotatePoint(
    item.x + item.width,
    item.y + item.height,
    item.centerX,
    item.centerY,
    item.rotate
  );
  let tranX = tranPosition[0] - TRAN_W / 2;
  let tranY = tranPosition[1] - TRAN_H / 2;
  if (
    x - tranX >= 0 &&
    y - tranY >= 0 &&
    tranX + TRAN_W - x >= 0 &&
    tranY + TRAN_H - y >= 0
  ) {
    return STATUS_TRANSFORM;
  } else if (this.insideEle(x, y, item)) {
    return STATUS_MOVE;
  }
  // 不在元素区域里面并且不在元素操作按钮区域内
  return false;
}

新增完这个函数我们接着改造鼠标点击函数

handleDown(e) {
  // 省略之前的代码...

  // 临时元素状态
  let tempStatus = null;

  // 省略之前的代码...

  this.elementArray.forEach(item => {
    let status = this.isEleClickZone(x, y, item);

    // 选中点击坐标下顶层元素
    if (
      (status && !currItem) ||
      (status && currItem.zIndex < item.zIndex)
    ) {
      currItem = item;
      // 更改临时状态
      tempStatus = status;
    }
  });

  if (this.currItem) {
    // 新增元素状态
    this.currItem.status = tempStatus;
    // 记录选中元素的部分参数,用于后面计算
    this.currItemStartOpt = {
      // 省略之前的代码...

      // 新增存储点击时当前元素的旋转角度
      rotate: this.currItem.rotate
    };
  }
}

因为加入了元素的状态,在不同的状态进行不同的操作,之前移动的时候都是写在鼠标移动函数中的,后面的状态多了,响应不同状态的操作,这样会导致鼠标移动函数难以维护,所以提取处理元素状态函数单独维护,状态处理函数抽离,使其功能职责单一,因为在变换过程中,元素会进行缩放,不能无限的缩小,所以定义一个最小宽度常量

const MIN_WIDTH = 40;

/**
 * 抽离函数处理元素移动
 *
 * @param {Number} x
 * @param {Number} y
 */
handleEleMove(x, y) {
  const temp = this.currItemStartOpt;

  // 处理元素移动方法
  // 通过鼠标点击位置来计算中心点,
  //   再通过中心点去计算元素的 xy
  // 不考虑边界情况的中心点坐标
  let _centerX = temp.centerX + x - this.startX;
  let _centerY = temp.centerY + y - this.startY;
  // 最小中心点坐标,用于处理边界情况
  let minCenterX = temp.width / 2;
  let minCenterY = temp.height / 2;
  // 最大中心点坐标,用于处理边界情况
  let maxCenter = this.width - minCenterX;
  let maxHeight = this.height - minCenterY;
  // 最终计算出的中心点坐标
  let centerX = _centerX >= maxCenter ?
    maxCenter :
    _centerX <= minCenterX ?
    minCenterX : _centerX;
  let centerY = _centerY >= maxHeight ?
    maxHeight :
    _centerY <= minCenterY ?
    minCenterY : _centerY;

  this.currItem.centerX = centerX;
  this.currItem.centerY = centerY;
  this.currItem.x = this.currItem.centerX - minCenterX;
  this.currItem.y = this.currItem.centerY - minCenterY;
}

/**
 * 新增处理元素变换
 *
 * @param {Number} x
 * @param {Number} y
 */
handleEleTran(x, y) {
  const currItem = this.currItem;
  const {
    x: oX,
    y: oY,
    rotate: oRotate,
    width: oWidth,
    height: oHeight
  } = this.currItemStartOpt;

  // 重新计算元素的中心点坐标
  currItem.centerX = currItem.x + currItem.width / 2;
  currItem.centerY = currItem.y + currItem.height / 2;
  
  // 计算旋转角度
  let diffStartX = this.startX - currItem.centerX;
  let diffStartY = this.startY - currItem.centerY;
  let diffEndX = x - currItem.centerX;
  let diffEndY = y - currItem.centerY;
  let angleStart = Math.atan2(diffStartY, diffStartX) / Math.PI * 180;
  let angleEnd = Math.atan2(diffEndY, diffEndX) / Math.PI * 180;

  currItem.rotate = oRotate + angleEnd - angleStart;

  // 利用中心点计算鼠标移动前后点距离,
  // 用于计算缩放比例,
  // 再基于这个比例重新计算元素宽高
  let lineStart = Math.sqrt(
    Math.pow(currItem.centerX - this.startX, 2) +
    Math.pow(currItem.centerY - this.startY, 2)
  );
  let lineEnd = Math.sqrt(
    Math.pow(currItem.centerX - x, 2) +
    Math.pow(currItem.centerY - y, 2)
  );
  let resizeRaito = lineEnd / lineStart;
  let newW = oWidth * resizeRaito;
  let newH = oHeight * resizeRaito;

  // 以短边为基准来计算最小宽高
  if (oWidth <= oHeight && newW < MIN_WIDTH) {
    newW = MIN_WIDTH;
    newH = MIN_WIDTH * oHeight / oWidth;
  } else if (oHeight < oWidth && newH < MIN_WIDTH) {
    newH = MIN_WIDTH;
    newW = MIN_WIDTH * oWidth / oHeight;
  }

  // 以长边为基准来计算最大宽高
  if (oWidth >= oHeight && newW >= this.width) {
    newW = this.width;
    newH = this.width * oHeight / oWidth;
  } else if (oHeight > oWidth && newH >= this.height) {
    newH = this.height;
    newW = this.height * oWidth / oHeight;
  }

  currItem.width = Math.round(newW);
  currItem.height = Math.round(newH);
  currItem.x = Math.round(oX - (newW - oWidth) / 2);
  currItem.y = Math.round(oY - (newH - oHeight) / 2);
}

改造鼠标移动函数

handleMove(e) {
  requestAnimationFrame(() => {
    if (!this.currItem || !this.currItemStartOpt) return;

    const { clientX, clientY } = e;
    let x = clientX - this.offsetX + this.scrollLeft;
    let y = clientY - this.offsetY + this.scrollTop;
    let status = this.currItem.status;

    if (status === STATUS_MOVE) {
      this.handleEleMove(x, y);
    } else if (status === STATUS_TRANSFORM) {
      this.handleEleTran(x, y);
    }
  });
}

观察上面改造后的函数,如果我们后面还要加很多状态,就要写很多的 if-else,所以使用策略模式改造,抽离处理函数,所以继续改造

/**
 * 抽离函数处理元素 status,对应的 status 做对应的事情
 * 
 * @param {Number} x 移动的 x 坐标
 * @param {Number} y 移动的 y 坐标
 */
handleEleStatus(x, y) {
  // 之后需要响应其他的状态,接着定义状态常量,
  // 写状态函数接着往下添加即可
  return {
    [STATUS_MOVE]: () => this.handleEleMove(x, y),
    [STATUS_TRANSFORM]: () => this.handleEleTran(x, y)
  }
}

handleMove(e) {
  requestAnimationFrame(() => {
    if (!this.currItem || !this.currItemStartOpt) return;

    const { clientX, clientY } = e;
    let x = clientX - this.offsetX + this.scrollLeft;
    let y = clientY - this.offsetY + this.scrollTop;
    let status = this.currItem.status;

    // if (status === STATUS_MOVE) {
    //   this.handleEleMove(x, y);
    // } else if (status === STATUS_TRANSFORM) {
    //   this.handleEleTran(x, y);
    // }
    this.handleEleStatus(x, y)[status]();
  });
}

最后,当鼠标按键抬起的时候,将当前选中元素状态置空

handleUp(e) {
  if (this.currItem) {
    this.currItem.status = null;
  }
  this.currItem = null;
  this.currItemStartOpt = null;
}

关于旋转变换操作涉及到修改的代码已经完成,接下来看看效果

镜像snownlp_镜像snownlp

至此我们实现了元素的旋转变换,其中涉及到的变换多用到了数学的知识,这样是不是也知道数学的重要性了,哈哈哈哈,后面完全实现了之后再贴上完整版的代码。