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

148 阅读5分钟

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

其实这个功能在网上随便一找就是一大堆,但为了方便自己,还是写一下,这里是一个简单的 demo,使用的是 js 的方式,后面再基于此融合到框架中去,或者适配移动端。

这个功能都很清楚了,所以简单的做下分析。

  • 要做到元素的这些操作,我们得限制在一个容器当中,所以我们得先有一个容器
  • 实现元素的这些操作,PC 端目前常规的操作就是通过鼠标来实现的,所以得监听鼠标的事件,通过鼠标点击坐标和容器的一些参数来计算元素的位置,大小
  • 然后没了~

实现

<style>
  .container {
    width: 500px;
    height: 500px;
    border: 1px solid #CCC;
  }
</style>

<!-- <div id="parentBox" style="height: 200px;overflow: scroll;"> -->
  <div id="eleBox" class="container"></div>
<!-- </div> -->

<div>
  <button id="addBtn">新增</button>
  <button id="clearBtn">清空</button>
  <button id="unloadBtn">卸载</button>
</div>
const addBtn = document.getElementById('addBtn');
const clearBtn = document.getElementById('clearBtn');
const unloadBtn = document.getElementById('unloadBtn');
const parentBox = document.getElementById('parentBox');

class EleDrop {
  // id: 元素容器 id
  // scrollTop: 如果父元素存在 scrollTop, 就设置
  // scrollLeft: 如果父元素存在 scrollLeft, 就设置
  constructor(id, scrollTop = 0, scrollLeft = 0) {
    if (typeof id !== 'string' || id === '') {
      throw new Error('请传入正确的容器id');
      return
    }

    this.elementContainer = document.getElementById(id);

    if (!this.elementContainer) {
      throw new Error('未找到传入id的容器: ' + id);
      return
    }

    this.elementContainer.style.position = 'relative';

    // 容器元素的偏移量
    // 如果不考虑兼容,直接使用子元素的 offset 来计算,则可以不需要这个
    this.offsetX = this.elementContainer.offsetLeft;
    this.offsetY = this.elementContainer.offsetTop;
    // 容器的宽高,不包含边框这些......
    this.width = this.elementContainer.clientWidth;
    this.height = this.elementContainer.clientHeight;
    // 预留scroll,
    // 如果页面存在滚动条的时候,
    // 滚动条的位置也会影响点击的位置
    // 如果不考虑兼容,也可以和 offset 一样
    this.scrollTop = scrollTop;
    this.scrollLeft = scrollLeft;
    this.startX = 0;
    this.startY = 0;
    this.currItem = null;
    this.currItemStartOpt = null;
    this.fnDown = (e) => this.handleDown(e);
    this.fnMove = (e) => this.handleMove(e);
    this.fnUp = (e) => this.handleUp(e);

    this.elementContainer.addEventListener('mousedown', this.fnDown);
    document.addEventListener('mousemove', this.fnMove);
    document.addEventListener('mouseup', this.fnUp);

    const _this = this;

    // 可以根据实际情况更改,
    // 为了方便操作数组的时候自动调用更新,
    // 后期融合到框架中可以删除
    this.elementArray = new Proxy([], {
      set(target, property, value, receiver) {
        _this.render();
        return Reflect.set(target, property, value, receiver);
      }
    });
  }

  /**
   * 卸载
   */
  unload() {
    this.elementContainer.removeEventListener('mousedown', this.fnDown);
    document.removeEventListener('mousemove', this.fnMove);
    document.removeEventListener('mouseup', this.fnUp);
  }

  /**
   * 设置滚动条
   *
   * @param {Number} top 滚动条 top 值
   * @param {Number} left 滚动条 left 值
   */
  setScroll(top, left) {
    this.scrollLeft = left;
    this.scrollTop = top;
  }

  /**
   * 处理鼠标按下事件
   *
   * @param {*} e 事件参数
   */
  handleDown(e) {
    // const { clientX, clientY, layerX, layerY } = e;
    // let x = 0;
    // let y = 0;
    
    // if (layerX && layerY) {
    //   // layerX 和 layerY
    //   // 在实际验证中是绑定鼠标事件元素,点击位置为相对于左上角的偏移量
    //   // 好用,但是 MDN 上说是实验性的,会存在兼容
    //   x = layerX;
    //   y = layerY;
    // } else {
    //   // 点击坐标去掉容器偏移量加上滚动条的位置得到点击坐标在容器区域内的真实坐标
    //   // 为什么要得到真实的坐标?
    //   //   因为如果容器存在偏移且外层元素有滚动距离的时候,
    //   //   再用 clientX 与 clientY 就无法判断元素点击区域是否在元素内部了
    //   // 为什么不用 offsetX 和 offsetY?
    //   //   在实际验证中火狐浏览器得到的是 0
    //   x = clientX - this.offsetX + this.scrollLeft;
    //   y = clientY - this.offsetY + this.scrollTop;
    // }

    // 为了兼容直接通过 clientX 与 clientY 计算,
    // 只不过这样得让外界传递滚动条位置参数
    //   如果元素的父级元素有几个都存在滚动条,
    //   那么传递进来的就要是所有的滚动条位置参数相加
    // 实际使用中就看实际情况了
    const { clientX, clientY } = e;
    let x = clientX - this.offsetX + this.scrollLeft;
    let y = clientY - this.offsetY + this.scrollTop;

    let currItem = null;

    // 记录初始点击坐标
    this.startX = x;
    this.startY = y;

    this.elementArray.forEach(item => {
      let _action = this.insideEle(x, y, item);

      // 选中点击坐标下的顶层元素
      if (
        (_action && !currItem) ||
        (_action && currItem.zIndex < item.zIndex))
      {
        currItem = item;
      }
    });

    this.currItem = currItem;

    if (this.currItem) {
      // 记录选中元素的部分参数,用于后面计算
      this.currItemStartOpt = {
        centerX: this.currItem.centerX,
        centerY: this.currItem.centerY,
        width: this.currItem.width,
        height: this.currItem.height
      };
    }
  }

  /**
   * 处理鼠标移动事件
   *
   * @param {*} e 事件参数
   */
  handleMove(e) {
    requestAnimationFrame(() => {
      if (!this.currItem || !this.currItemStartOpt) return;

      const { clientX, clientY } = e;

      this.handleEleMove(clientX, clientY);
    });
  }

  /**
   * 处理鼠标按键松开
   *
   * @param {*} e 事件参数
   */
  handleUp(e) {
    this.currItem = null;
    this.currItemStartOpt = null;
  }

  /**
   * 处理元素移动
   *
   * @param {Number} clientX
   * @param {Number} clientY
   */
  handleEleMove(clientX, clientY) {
    const temp = this.currItemStartOpt;

    // // 处理元素移动方法1
    // // 元素原来的 xy 加上移动的距离就是移动后的 xy
    // // 计算完成后将 startX 与 startY 重置为本次移动后的 xy
    // let tempX = clientX - this.offsetX + this.scrollLeft;
    // let tempY = clientY - this.offsetY + this.scrollTop;
    // let _x = this.currItem.x + tempX - this.startX;
    // let _y = this.currItem.y + tempY - this.startY;
    // let maxX = this.width - this.currItem.width / 2;
    // let maxY = this.height - this.currItem.height / 2;
    // // 处理边界情况
    // let x = _x <= 0 ? 0 : _x >= maxX ? maxX : _x;
    // let y = _y <= 0 ? 0 : _y >= maxY ? maxY : _y;
    // this.currItem.x = x;
    // this.currItem.y = y;
    // this.startX = tempX;
    // this.startY = tempY;

    // 处理元素移动方法2
    // 通过鼠标点击位置来计算中心点,
    //   再通过中心点去计算元素的 xy
    // 不考虑边界情况的中心点坐标
    let _centerX = temp.centerX + clientX - this.offsetX + this.scrollLeft - this.startX;
    let _centerY = temp.centerY + clientY - this.offsetY + this.scrollTop - 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 {Object} options 元素属性
   */
  addElement(options = {}) {
    const item = this.handleOptions(options);

    this.elementArray.push(item);
  }

  /**
   * 处理元素属性
   *
   * @param {Object} options 元素属性
   */
  handleOptions(options = {}) {
    const _options = {};
    const {
      id,
      width,
      height,
      x,
      y,
      backgroundColor,
      zIndex
    } = options;

    _options.id = id ? id : Date.now();
    _options.width = width ? width : 50;
    _options.height = height ? height : 50;
    _options.x = x ? x : 10;
    _options.y = y ? y : 10;
    _options.backgroundColor = backgroundColor ? backgroundColor : '#CCC';
    // 元素的顶点坐标
    _options.square = [
      [_options.x, _options.y],
      [_options.x + _options.width, _options.y],
      [_options.x + _options.width, _options.y + _options.height],
      [_options.x, _options.y + _options.height]
    ];
    // 元素的中心点坐标
    _options.centerX = (_options.x + _options.width) / 2;
    _options.centerY = (_options.y + _options.height) / 2;
    // 元素层级
    _options.zIndex = zIndex ? zIndex : this.genZIndex();

    const _this = this;

    // 当元素的属性发生更改的时候,重新执行渲染
    // 当元素的 xy 发生改变的时候,重新计算顶点坐标
    return new Proxy(_options, {
      set(target, property, value, receiver) {
        if (
          property === 'x' ||
          property === 'y'
        ) {
          _this.rotateSquare();
        }
        _this.render();

        return Reflect.set(target, property, value, receiver);
      }
    });
  }

  /**
   * 生成元素数组中的 zIndex
   *
   * @return 返回最大 zIndex + 1
   */
  genZIndex() {
    if (this.elementArray.length === 0) return 1;

    let maxZIndex = Math.max(
      ...this.elementArray.map(o => o.zIndex)
    );

    return maxZIndex + 1;
  }

  /**
   * 渲染
   */
  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);"
        >  
        </div>
      `;
    });

    this.elementContainer.innerHTML = str;
  }

  /**
   * 判断点击坐标是否在元素内
   *
   * @param {Number} x 点击 x 坐标
   * @param {Number} y 点击 y 坐标
   * @param {Object} item 要判断的元素
   * @return 在元素内返回 true, 不在元素内返回 false
   */
  insideEle(x, y, item) {
    const square = item.square;
    let inside = false;

    for (let i = 0, j = square.length - 1; i < square.length; j = i++) {
      let xi = square[i][0];
      let yi = square[i][1];
      let xj = square[j][0];
      let yj = square[j][1];
      let intersect = yi > y != yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi;

      if (intersect) inside = !inside;
    }

    return inside;
  }

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

    if (!currItem) return;

    currItem.square = [
        [currItem.x, currItem.y],
        [currItem.x + currItem.width, currItem.y],
        [currItem.x + currItem.width, currItem.y + currItem.height],
        [currItem.x, currItem.y + currItem.height]
    ];
  }
}

const eleDrop = new EleDrop('eleBox');
// const eleDrop = new EleDrop('eleBox', parentBox.scrollTop, parentBox.scrollLeft);

// document.addEventListener('scroll', () => {
//   eleDrop.setScroll(
//     document.documentElement.scrollTop,
//     document.documentElement.scrollLeft
//   );
// });
// parentBox.addEventListener('scroll', () => {
//   eleDrop.setScroll(
//     parentBox.scrollTop,
//     parentBox.scrollLeft
//   );
// });
addBtn.addEventListener('click', () => {
  eleDrop.addElement();
});
unloadBtn.addEventListener('click', () => {
  eleDrop.unload();
});

最终效果

CPT2309111749-509x541.gif

初步实现了元素的移动,因为篇幅,到此结束,后面基于此再实现其他功能