前言

本文介绍fabricjs的部分功能(拖拽,图层,选择,缩放,旋转等)的实现思路,水平有限仅供参考。

项目git仓库 : https://github.com/pengzhijian/easy-fabricjs, 在最底部也有所有效果的完整代码,有需要的自取。

完整效果展示:

recording.gif

1. 拖拽

1. 单个元素拖拽

单元素拖拽只需要当位置在方块区域时,检测鼠标按下位置,按下后的偏移量,然后改变位置重绘即可。

<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      #canvas {
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <script>
      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");
      const square = {
        x: 50,
        y: 50,
        size: 50,
        color: "blue",
        isDragging: false,
      };

      let startX, startY; // 记录鼠标按下时鼠标的坐标
      let lastSquareX = square.x, lastSquareY = square.y; // 记录每次按下时方块的起始坐标
      let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量
      // 绘制方块
      function drawSquare() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = square.color;
        ctx.fillRect(square.x, square.y, square.size, square.size);
      }

      // 判断是否鼠标在方块内
      function isMouseInSquare(mouseX, mouseY) {
        return (
          mouseX > square.x &&
          mouseX < square.x + square.size &&
          mouseY > square.y &&
          mouseY < square.y + square.size
        );
      }

      canvas.addEventListener("mousedown", (e) => {
        const mouseX = e.offsetX;
        const mouseY = e.offsetY;
        startX = e.clientX - offsetX;
        startY = e.clientY - offsetY;
        if (isMouseInSquare(mouseX, mouseY)) {
          lastSquareX = square.x;
          lastSquareY = square.y;
          canvas.style.cursor = "grabbing";
          square.isDragging = true;
        }
      });

      canvas.addEventListener("mousemove", (e) => {
        if (square.isDragging) {
          canvas.style.cursor = "grabbing";
          const mouseX = e.offsetX;
          const mouseY = e.offsetY;
          offsetX = e.clientX - startX;
          offsetY = e.clientY - startY;
          square.x = lastSquareX + offsetX;
          square.y = lastSquareY + offsetY;
          drawSquare();
        }
      });

      canvas.addEventListener("mouseup", () => {
        square.isDragging = false;
        lastSquareX = square.x;
        lastSquareY = square.y;
        offsetX = 0;
        offsetY = 0;
        canvas.style.cursor = "default";
      });

      canvas.addEventListener("mouseleave", () => {
        square.isDragging = false;
        lastSquareX = square.x;
        lastSquareY = square.y;
        offsetX = 0;
        offsetY = 0;
        canvas.style.cursor = "default";
      });

      drawSquare();
    </script>
  </body>
</html>


实现效果:

recording.gif

2. 多元素拖拽

多元素拖拽需要将上面的拖拽元素抽象出来成为一个可复用的方法,此处采用Class实现。

      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");

      // 判断是否鼠标在方块内
      function isMouseInSquare(mouseX, mouseY, squareSettings) {
        return (
          mouseX > squareSettings.x &&
          mouseX < squareSettings.x + squareSettings.width &&
          mouseY > squareSettings.y &&
          mouseY < squareSettings.y + squareSettings.height
        );
      }

      class mySquare {
        isDragging = false; // 方块是否被拖拽
        constructor(canvas, squareSettings) {
          this.x = squareSettings.x; // 方块的x坐标
          this.y = squareSettings.y; // 方块的y坐标
          this.width = squareSettings.width; // 方块的宽度
          this.height = squareSettings.height; // 方块的高度
          this.color = squareSettings.color; // 方块的颜色
          this.canvas = canvas; // 画布

          this.squareHandler(canvas); // 注册方块的鼠标点击移动等事件

          this.drawSquare(canvas); // 绘制方块
        }

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.fillStyle = this.color;
          ctx.fillRect(this.x, this.y, this.width, this.height);
        }

        // 方块的鼠标点击移动等事件
        squareHandler(canvas) {
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          // 方法单列出来,方便后续注销事件
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (isMouseInSquare(mouseX, mouseY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height
            })) {
              lastSquareX = this.x;
              lastSquareY = this.y;
              canvas.style.cursor = "grabbing";
              this.isDragging = true;
            }
          }

          const mousemoveHandler = (e) => {
            if (this.isDragging) {
              canvas.style.cursor = "grabbing";
              const mouseX = e.offsetX;
              const mouseY = e.offsetY;
              offsetX = e.clientX - startX;
              offsetY = e.clientY - startY;
              this.x = lastSquareX + offsetX;
              this.y = lastSquareY + offsetY;
              this.drawSquare(canvas);
            }
          }

          const mouseupHandler = (e) => {
            this.isDragging = false;
            lastSquareX = this.x;
            lastSquareY = this.y;
            offsetX = 0;
            offsetY = 0;
            canvas.style.cursor = "default";
          }

          // 注册鼠标事件
          canvas.addEventListener("mousedown", mousedownHandler);
          canvas.addEventListener("mousemove", mousemoveHandler);
          canvas.addEventListener("mouseup", mouseupHandler);
          canvas.addEventListener("mouseleave", mouseupHandler);
        }
      }

      const square1 = new mySquare(canvas, {
        x: 100,
        y: 100,
        width: 100,
        height: 100,
        color: "red"
      });

      const square2 = new mySquare(canvas, {
        x: 0,
        y: 0,
        width: 50,
        height: 50,
        color: "yellow"
      });

封装后运行代码,发现只画了最后一次注册的方块:

image.png

由于这两个 mySquare 对象共用了一个 ctx 对象,所以每次清除重绘都会将别的 mySquare 对象绘制的内容清空,为了让每个 mySquare 对象互不干扰,要使用图层将他们区分开来后再由 ctx 统一处理。

3. 图层

1. 添加图层

所有mySquare对象都是同一个 canvas 以及 ctx 对象,此处只需要在 ctx 上新增属性即可管理所有的mySquare对象。

此处新增了4个属性:

  1. level: 代表图层的最大值为多少。
  2. nowLevel: 当前绘制的图层是第几个。
  3. drawItemList: 存储每一个新增的mySquare对象。
  4. draw(): ctx 的统一绘画方法。

类中新增 ctxDraw 方法:

        // 将图层挂载在ctx上再统一绘制
        ctxDraw(canvas) {
          const ctx = canvas.getContext("2d");
          // 将图层挂载在ctx上
          if (!ctx.level) {
            ctx.level = 0;
          }
          ctx.level++; // 图层数加1
          this.level = ctx.level; // 记录此对象的图层数

          // 存储每一个新增的mySquare对象
          if (!ctx.drawItemList) {
            ctx.drawItemList = [];
          }
          ctx.drawItemList.push(this);

          // 添加ctx的统一绘画方法
          if (!ctx.draw) {
            ctx.draw = () => {
              ctx.clearRect(0, 0, canvas.width, canvas.height);
              for (let i = 0; i <= ctx.level; i++) {
                ctx.nowLevel = i;
                ctx.drawItemList.forEach((item) => {
                  item.drawSquare(canvas);
                });
              }
            };
          }
        }

改造drawSquare方法:

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.save();
            ctx.fillStyle = this.color;
            ctx.fillRect(this.x, this.y, this.width, this.height);
            ctx.restore();
          }
        }

完整代码:

      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");

      // 判断是否鼠标在方块内
      function isMouseInSquare(mouseX, mouseY, squareSettings) {
        return (
          mouseX > squareSettings.x &&
          mouseX < squareSettings.x + squareSettings.width &&
          mouseY > squareSettings.y &&
          mouseY < squareSettings.y + squareSettings.height
        );
      }

      class mySquare {
        isDragging = false; // 方块是否被拖拽
        constructor(canvas, squareSettings) {
          this.x = squareSettings.x; // 方块的x坐标
          this.y = squareSettings.y; // 方块的y坐标
          this.width = squareSettings.width; // 方块的宽度
          this.height = squareSettings.height; // 方块的高度
          this.color = squareSettings.color; // 方块的颜色
          this.canvas = canvas; // 画布

          this.squareHandler(canvas); // 注册方块的鼠标点击移动等事件

          this.ctxDraw(canvas); // 注册ctx的统一绘画方法

          ctx.draw(); // 绘制所有图层
        }

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.save();
            ctx.fillStyle = this.color;
            ctx.fillRect(this.x, this.y, this.width, this.height);
            ctx.restore();
          }
        }

        // 将图层挂载在ctx上再统一绘制
        ctxDraw(canvas) {
          const ctx = canvas.getContext("2d");
          // 将图层挂载在ctx上
          if (!ctx.level) {
            ctx.level = 0;
          }
          ctx.level++; // 图层数加1
          this.level = ctx.level; // 记录此对象的图层数

          // 存储每一个新增的mySquare对象
          if (!ctx.drawItemList) {
            ctx.drawItemList = [];
          }
          ctx.drawItemList.push(this);

          // 添加ctx的统一绘画方法
          if (!ctx.draw) {
            ctx.draw = () => {
              ctx.clearRect(0, 0, canvas.width, canvas.height);
              for (let i = 0; i <= ctx.level; i++) {
                ctx.nowLevel = i;
                ctx.drawItemList.forEach((item) => {
                  item.drawSquare(canvas);
                });
              }
            };
          }
        }

        // 方块的鼠标点击移动等事件
        squareHandler(canvas) {
          const ctx = canvas.getContext("2d");

          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          // 方法单列出来,方便后续注销事件
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (isMouseInSquare(mouseX, mouseY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height
            })) {
              lastSquareX = this.x;
              lastSquareY = this.y;
              canvas.style.cursor = "grabbing";
              this.isDragging = true;
            }
          }

          const mousemoveHandler = (e) => {
            if (this.isDragging) {
              canvas.style.cursor = "grabbing";
              const mouseX = e.offsetX;
              const mouseY = e.offsetY;
              offsetX = e.clientX - startX;
              offsetY = e.clientY - startY;
              this.x = lastSquareX + offsetX;
              this.y = lastSquareY + offsetY;
              ctx.draw();
            }
          }

          const mouseupHandler = (e) => {
            this.isDragging = false;
            lastSquareX = this.x;
            lastSquareY = this.y;
            offsetX = 0;
            offsetY = 0;
            canvas.style.cursor = "default";
          }

          // 注册鼠标事件
          canvas.addEventListener("mousedown", mousedownHandler);
          canvas.addEventListener("mousemove", mousemoveHandler);
          canvas.addEventListener("mouseup", mouseupHandler);
          canvas.addEventListener("mouseleave", mouseupHandler);
        }
      }

      const square1 = new mySquare(canvas, {
        x: 100,
        y: 100,
        width: 100,
        height: 100,
        color: "red"
      });

      const square2 = new mySquare(canvas, {
        x: 0,
        y: 0,
        width: 50,
        height: 50,
        color: "yellow"
      });

实现效果:

recording.gif

可以在控制台查看移动后的对象属性:

image.png

2. 图层优化

此时我们所有的对象图层是固定的,从上图可以发现,黄色方块永远在红色方块上面,显然这是不合理的,此处添加图层优化,让每次点击的对象处于图层的最上方,且每次点击只会选择最上方图形。

在mousedownHandler中新增代码:

              // 当点击的对象不为最高层级时,将其挂载在最高层级
              if (ctx.level !== this.level) {
                ctx.drawItemList.forEach((item) => {
                  item.isDragging = false;
                  item.isSelected = false;
                  // 将之前比当前对象图层数高的对象减1
                  if (item.level > this.level) {
                    item.level--;
                  }
                });
                this.isDragging = true
                this.isSelected = true;
                this.level = ctx.level; // 提到最高层级
                // 重新注册鼠标事件,这个代表着执行顺序,最新注册的最后执行
                canvas.removeEventListener('mousedown', this.mousedownHandler)
                canvas.removeEventListener('mousemove', this.mousemoveHandler)
                canvas.addEventListener('mousedown', this.mousedownHandler)
                canvas.addEventListener('mousemove', this.mousemoveHandler)
              }

recording.gif

3. 选中功能

此时方块可以被选择拖中,但是并没有显示具体被选择方块的特征,故加一个边框,代表方块被选中。

类中新增边框的方法:

        // 画选中后的框框
        drawBorderSquare(canvas, setting) {
          const ctx = canvas.getContext("2d");
          ctx.save();
          ctx.strokeStyle = '#51B9F9';
          ctx.lineWidth = setting.borderLineWidth; // 边框宽度
          ctx.strokeRect(this.x - Math.floor(setting.borderLineWidth / 2), this.y - Math.floor(setting.borderLineWidth / 2), this.width + setting.borderLineWidth, this.height + setting.borderLineWidth)
          // 恢复到之前的状态
          ctx.restore();

          // 画边框的5个圆
          this.drawCircle(ctx, this.x, this.y);
          this.drawCircle(ctx, this.x + this.width, this.y);
          this.drawCircle(ctx, this.x, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width / 2, this.y + this.height + 15);
        }

        // 画选中的5个圆
        drawCircle(ctx, x, y) {
          ctx.save();
          ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色
          ctx.shadowBlur = 5; // 阴影模糊级别
          ctx.shadowOffsetX = 0; // 阴影的水平偏移
          ctx.shadowOffsetY = 0; // 阴影的垂直偏移
          ctx.beginPath();
          ctx.fillStyle = 'white';
          ctx.arc(x, y, 6, 0, 2 * Math.PI);
          ctx.fill();
          ctx.restore();
        }

drawSquare方法中新增边框绘画代码:

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.save();
            ctx.fillStyle = this.color;
            ctx.fillRect(this.x, this.y, this.width, this.height);
            ctx.restore();
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: 2
              });
            }
          }
        }

鼠标事件修改新增的被选择判断属性isSelected:

image.png

展示效果:

recording.gif

4. 拉伸功能

1. 边框拉伸

边框拉伸需先检测鼠标点击是否在边框范围中,然后再进行缩放即可,缩放方法详细可见我上篇文章[canvas实现中心旋转,各个顶点缩放](https://juejin.cn/post/7382592324079403027)

新增缩放工具方法:
      /**
       * 缩放功能
       * callback 绘制旋转矩形的函数
       * mode 从什么地方缩放
       * setting.rectX 矩形 x 坐标
       * setting.rectY 矩形 y 坐标
       * setting.width 矩形宽度
       * setting.height 矩形高度
       * setting.scaleX 缩放比例 x
       * setting.scaleY 缩放比例 y
       */
      function scaleRect(ctx, setting, callback) {
        const { rectX, rectY, width, height, scaleX, scaleY, scaleMode } = setting;
        ctx.save();
        ctx.translate(rectX, rectY); // 平移到 (0, 0)
        ctx.scale(scaleX, scaleY);
        let translateX = 0;
        let translateY = 0;
        if (scaleMode === 'left-top') {
          // 左上角点固定的缩放
        } else if (scaleMode == 'right-top') {
          // 右上角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
        } else if (scaleMode == 'left-bottom') {
          // 左下角点固定的缩放
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        } else if (scaleMode == 'right-bottom') {
          // 右下角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        }
        ctx.translate(translateX, translateY);
        ctx.translate(-rectX, -rectY); // 平移回到原点
        if (callback) {
          callback();
        }
        ctx.restore(); // 恢复原始状态
        return {
          rectX: rectX + translateX * scaleX,
          rectY: rectY + translateY * scaleY,
          width: width * scaleX,
          height: height * scaleY,
        }
      }
边框拉伸:

实现思路:用上面的缩放工具方法得到缩放后新的x,y,width,height值,然后赋值给方块。主要代码如下:

类中新增 isMouseInBorder 方法,判断鼠标是否在各个边框中:

        // 判断鼠标是否在边框内
        isMouseInBorder(mouseX, mouseY, position) {
          if (position === 'left') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10
            })
          } else if (position === 'right') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10
            })
          } else if (position === 'top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y - 5,
              width: this.width - 10,
              height: this.borderLineWidth + 5
            })
          } else if (position === 'bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y + this.height - this.borderLineWidth,
              width: this.width - 10,
              height: this.borderLineWidth + 5
            })
          }
        }

类中新增 scaleMouseHandler 方法处理拉伸缩放效果:

注意:此处我判断了最小缩放大小为20,如不设置可以为负数翻转,但是需要重新修改鼠标判定逻辑。

        // 边框和四角拉伸功能
        scaleMouseHandler(canvas) {
          const ctx = canvas.getContext("2d");
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量
          let startWidth, startHeight; // 记录鼠标按下时方块的宽度和高度
          let startRectX, startRectY; // 记录鼠标按下时方块的坐标
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            offsetX = 0;
            offsetY = 0;
            startWidth = this.width;
            startHeight = this.height;
            startRectX = this.x;
            startRectY = this.y;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left')) {
                // 检测是否在左边框内
                this.isScaled = 'left';
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right')) {
                // 检测是否在右边框内
                this.isScaled = 'right';
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top')) {
                // 检测是否在上边框内
                this.isScaled = 'top';
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom')) {
                // 检测是否在上边框内
                this.isScaled = 'bottom';
                canvas.style.cursor = "ns-resize";
              }
            }
          }

          const mousemoveHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left')) {
                // 检测是否在左边框内
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right')) {
                // 检测是否在右边框内
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top')) {
                // 检测是否在上边框内
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom')) {
                // 检测是否在上边框内
                canvas.style.cursor = "ns-resize";
              } else if (!this.isDragging && !this.isScaled ) {
                // 不在边框内时,且不在拉伸和拖拽状态时,回复鼠标指针
                canvas.style.cursor = "default";
              }

              if (this.isScaled) {
                // 边框拉伸功能
                offsetX = e.clientX - startX;
                offsetY = e.clientY - startY;
                if (this.isScaled === 'left') {
                  // 左边框拉伸
                  const tmpInfo = scaleRect(ctx, {
                    rectX: startRectX,
                    rectY: startRectY,
                    width: startWidth,
                    height: startHeight,
                    scaleX: (startWidth - offsetX) / startWidth,
                    scaleY: 1,
                    scaleMode: 'right-top'
                  })
                  if (tmpInfo.width >= 20) {
                    this.x = tmpInfo.rectX;
                    this.width = tmpInfo.width;
                    this.y = tmpInfo.rectY;
                    this.height = tmpInfo.height;
                    ctx.draw();
                  }
                } else if (this.isScaled === 'right') {
                  // 右边框拉伸
                  const tmpInfo = scaleRect(ctx, {
                    rectX: startRectX,
                    rectY: startRectY,
                    width: startWidth,
                    height: startHeight,
                    scaleX: (startWidth + offsetX) / startWidth,
                    scaleY: 1,
                    scaleMode: 'left-top'
                  })
                  if (tmpInfo.width >= 20) {
                    this.x = tmpInfo.rectX;
                    this.width = tmpInfo.width;
                    this.y = tmpInfo.rectY;
                    this.height = tmpInfo.height;
                    ctx.draw();
                  }
                } else if (this.isScaled === 'top') {
                  // 上边框拉伸
                  const tmpInfo = scaleRect(ctx, {
                    rectX: startRectX,
                    rectY: startRectY,
                    width: startWidth,
                    height: startHeight,
                    scaleX: 1,
                    scaleY: (startHeight - offsetY) / startHeight,
                    scaleMode: 'left-bottom'
                  })
                  if (tmpInfo.height >= 20) {
                    this.x = tmpInfo.rectX;
                    this.width = tmpInfo.width;
                    this.y = tmpInfo.rectY;
                    this.height = tmpInfo.height;
                    ctx.draw();
                  }
                } else if (this.isScaled === 'bottom') {
                  // 右边框拉伸
                  const tmpInfo = scaleRect(ctx, {
                    rectX: startRectX,
                    rectY: startRectY,
                    width: startWidth,
                    height: startHeight,
                    scaleX: 1,
                    scaleY: (startHeight + offsetY) / startHeight,
                    scaleMode: 'left-top'
                  })
                  if (tmpInfo.height >= 20) {
                    this.x = tmpInfo.rectX;
                    this.width = tmpInfo.width;
                    this.y = tmpInfo.rectY;
                    this.height = tmpInfo.height;
                    ctx.draw();
                  }
                }
              }
            }
          }

          const mouseupHandler = (e) => {
            this.isScaled = false;
          }

          return {
            scaleMouseDown: mousedownHandler,
            scaleMouseMove: mousemoveHandler,
            scaleMouseUp: mouseupHandler
          }
        }

此时效果:

recording.gif

四个角拖拽的逻辑是一样的,处理完毕优化后的程序完整代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      #canvas {
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <script>
      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");

      // 判断是否鼠标在方块内
      const isMouseInSquare = (mouseX, mouseY, squareSettings, Rect) => {
        return (
          mouseX > squareSettings.x &&
          mouseX < squareSettings.x + squareSettings.width &&
          mouseY > squareSettings.y &&
          mouseY < squareSettings.y + squareSettings.height
        );
      }

      /**
       * 缩放功能
       * callback 绘制旋转矩形的函数
       * mode 从什么地方缩放
       * setting.rectX 矩形 x 坐标
       * setting.rectY 矩形 y 坐标
       * setting.width 矩形宽度
       * setting.height 矩形高度
       * setting.scaleX 缩放比例 x
       * setting.scaleY 缩放比例 y
       */
      function scaleRect(ctx, setting, callback) {
        const { rectX, rectY, width, height, scaleX, scaleY, scaleMode } = setting;
        ctx.save();
        ctx.translate(rectX, rectY); // 平移到 (0, 0)
        ctx.scale(scaleX, scaleY);
        let translateX = 0;
        let translateY = 0;
        if (scaleMode === 'left-top') {
          // 左上角点固定的缩放
        } else if (scaleMode == 'right-top') {
          // 右上角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
        } else if (scaleMode == 'left-bottom') {
          // 左下角点固定的缩放
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        } else if (scaleMode == 'right-bottom') {
          // 右下角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        }
        ctx.translate(translateX, translateY);
        ctx.translate(-rectX, -rectY); // 平移回到原点
        if (callback) {
          callback();
        }
        ctx.restore(); // 恢复原始状态
        return {
          rectX: rectX + translateX * scaleX,
          rectY: rectY + translateY * scaleY,
          width: width * scaleX,
          height: height * scaleY,
        }
      }

      /**
       * 判断是否鼠标是拉伸模式
       */
       function isScaleing(canvas) {
        if (canvas.style.cursor === 'n-resize' || canvas.style.cursor === 'e-resize' || canvas.style.cursor === 's-resize' || canvas.style.cursor === 'w-resize' || canvas.style.cursor === 'ew-resize' || canvas.style.cursor === 'ns-resize' || canvas.style.cursor === 'nesw-resize' || canvas.style.cursor === 'nwse-resize' || canvas.style.cursor === 'col-resize' || canvas.style.cursor === 'all-scroll') {
          return true;
        } else {
          return false;
        }
      }
      class mySquare {
        isDragging = false; // 方块是否被拖拽
        isSelected = false; // 方块是否被选中
        isScaled = false; // 方块是否正在被拉伸
        borderLineWidth = 2;
        angle = 0;
        constructor(canvas, squareSettings) {
          this.x = squareSettings.x; // 方块的x坐标
          this.y = squareSettings.y; // 方块的y坐标
          this.width = squareSettings.width; // 方块的宽度
          this.height = squareSettings.height; // 方块的高度
          this.color = squareSettings.color; // 方块的颜色
          this.canvas = canvas; // 画布

          this.squareHandler(canvas); // 注册方块的鼠标点击移动等事件

          this.ctxDraw(canvas); // 注册ctx的统一绘画方法

          ctx.draw(); // 绘制所有图层
        }

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.save();
            ctx.fillStyle = this.color;
            ctx.fillRect(this.x, this.y, this.width, this.height);
            ctx.restore();
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }

        // 将图层挂载在ctx上再统一绘制
        ctxDraw(canvas) {
          const ctx = canvas.getContext("2d");
          // 将图层挂载在ctx上
          if (!ctx.level) {
            ctx.level = 0;
          }
          ctx.level++; // 图层数加1
          this.level = ctx.level; // 记录此对象的图层数

          // 存储每一个新增的mySquare对象
          if (!ctx.drawItemList) {
            ctx.drawItemList = [];
          }
          ctx.drawItemList.push(this);

          // 添加ctx的统一绘画方法
          if (!ctx.draw) {
            ctx.draw = () => {
              ctx.clearRect(0, 0, canvas.width, canvas.height);
              for (let i = 0; i <= ctx.level; i++) {
                ctx.nowLevel = i;
                ctx.drawItemList.forEach((item) => {
                  item.drawSquare(canvas);
                });
              }
            };
          }
        }

        // 方块的鼠标点击移动等事件
        squareHandler(canvas) {
          const ctx = canvas.getContext("2d");

          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          const { dragMove, dragDown, dragUp } = this.dragMouseHandler(canvas);
          const { scaleMouseDown, scaleMouseMove, scaleMouseUp } = this.scaleMouseHandler(canvas);

          // 方法单列出来,方便后续注销事件
          this.mousedownHandler = (e) => {
            dragDown(e);
            scaleMouseDown(e);
          }

          this.mousemoveHandler = (e) => {
            dragMove(e);
            scaleMouseMove(e);
          }

          this.mouseupHandler = (e) => {
            dragUp(e);
            scaleMouseUp(e);
          }

          // 注册鼠标事件
          canvas.addEventListener("mousedown", this.mousedownHandler);
          canvas.addEventListener("mousemove", this.mousemoveHandler);
          canvas.addEventListener("mouseup", this.mouseupHandler);
          canvas.addEventListener("mouseleave", this.mouseupHandler);
        }

        // 边框和四角拉伸功能 旋转功能
        scaleMouseHandler(canvas) {
          const ctx = canvas.getContext("2d");
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量
          let startWidth, startHeight; // 记录鼠标按下时方块的宽度和高度
          let startRectX, startRectY; // 记录鼠标按下时方块的坐标
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            offsetX = 0;
            offsetY = 0;
            startWidth = this.width;
            startHeight = this.height;
            startRectX = this.x;
            startRectY = this.y;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left')) {
                // 检测是否在左边框内
                this.isScaled = 'left';
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right')) {
                // 检测是否在右边框内
                this.isScaled = 'right';
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top')) {
                // 检测是否在上边框内
                this.isScaled = 'top';
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom')) {
                // 检测是否在上边框内
                this.isScaled = 'bottom';
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-top')) {
                // 检测是否在左上角
                this.isScaled = 'left-top';
                canvas.style.cursor = "nwse-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-bottom')) {
                // 检测是否在左下角
                this.isScaled = 'left-bottom';
                canvas.style.cursor = "nesw-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-top')) {
                // 检测是否在右上角
                this.isScaled = 'right-top';
                canvas.style.cursor = "nesw-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-bottom')) {
                // 检测是否在右下角
                this.isScaled = 'right-bottom';
                canvas.style.cursor = "nwse-resize";
              }
            }
          }

          const mousemoveHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left') && !this.isScaled) {
                // 检测是否在左边框内
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right') && !this.isScaled) {
                // 检测是否在右边框内
                canvas.style.cursor = "ew-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top') && !this.isScaled) {
                // 检测是否在上边框内
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom') && !this.isScaled) {
                // 检测是否在下边框内
                canvas.style.cursor = "ns-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-top') && !this.isScaled) {
                // 检测是否在左上角
                canvas.style.cursor = "nwse-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-bottom') && !this.isScaled) {
                // 检测是否在左下角
                canvas.style.cursor = "nesw-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-top') && !this.isScaled) {
                // 检测是否在右上角
                canvas.style.cursor = "nesw-resize";
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-bottom') && !this.isScaled) {
                // 检测是否在右下角
                canvas.style.cursor = "nwse-resize";
              } else if (!this.isDragging && !this.isScaled ) {
                // 不在边框内时,且不在拉伸和拖拽状态时,回复鼠标指针
                canvas.style.cursor = "default";
              }

              if (this.isScaled) {
                // 边框拉伸功能
                offsetX = e.clientX - startX;
                offsetY = e.clientY - startY;
                let tempOffset = Math.abs(offsetX) > Math.abs(offsetY) ? offsetX : offsetY;
                const helpObj = {
                  startRectX: startRectX,
                  startRectY: startRectY,
                  startWidth: startWidth,
                  startHeight: startHeight,
                  startX: startX,
                  startY: startY,
                  scaleX: 1,
                  scaleY: 1,
                  scaleMode: 'left-top',
                  limitName: 'width'
                }
                if (this.isScaled === 'left') {
                  // 左边框拉伸
                  helpObj.scaleMode = 'right-top'
                  helpObj.scaleX = (startWidth - offsetX) / startWidth
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'right') {
                  // 右边框拉伸
                  helpObj.scaleMode = 'left-top'
                  helpObj.scaleX = (startWidth + offsetX) / startWidth
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'top') {
                  // 上边框拉伸
                  helpObj.limitName = 'height'
                  helpObj.scaleMode = 'left-bottom'
                  helpObj.scaleY = (startHeight - offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'bottom') {
                  // 下边框拉伸
                  helpObj.limitName = 'height'
                  helpObj.scaleMode = 'left-top'
                  helpObj.scaleY = (startHeight + offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'left-top') {
                  const tempScale = Math.abs(offsetX) > Math.abs(offsetY) ? (this.height - offsetY) / this.height : (this.width - offsetX) / this.width;
                  // 左上角拉伸
                  helpObj.scaleMode = 'right-bottom'
                  helpObj.scaleX = (startWidth - offsetX) / startWidth
                  helpObj.scaleY = (startHeight - offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'left-bottom') {
                  const tempScale = Math.abs(offsetX) > Math.abs(offsetY) ? (this.height - offsetY) / this.height : (this.width - offsetX) / this.width;
                  // 左下角拉伸
                  helpObj.limitName = 'height'
                  helpObj.scaleMode = 'right-top'
                  helpObj.scaleX = (startWidth - offsetX) / startWidth
                  helpObj.scaleY = (startHeight + offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'right-top') {
                  const tempScale = Math.abs(offsetX) > Math.abs(offsetY) ? (this.height - offsetY) / this.height : (this.width - offsetX) / this.width;
                  // 右上角拉伸
                  helpObj.limitName = 'height'
                  helpObj.scaleMode = 'left-bottom'
                  helpObj.scaleX = (startWidth + offsetX) / startWidth
                  helpObj.scaleY = (startHeight - offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'right-bottom') {
                  // 右下角拉伸
                  helpObj.limitName = 'height'
                  helpObj.scaleMode = 'left-top'
                  helpObj.scaleX = (startWidth + offsetX) / startWidth
                  helpObj.scaleY = (startHeight + offsetY) / startHeight
                  this.scaleHelpFunc(helpObj)
                }
              }
            }
          }

          const mouseupHandler = (e) => {
            this.isScaled = false;
          }

          return {
            scaleMouseDown: mousedownHandler,
            scaleMouseMove: mousemoveHandler,
            scaleMouseUp: mouseupHandler
          }
        }

        // 缩放的重复代码太多,抽离出来
        scaleHelpFunc(obj) {
          const tmpInfo = scaleRect(ctx, {
            rectX: obj.startRectX,
            rectY: obj.startRectY,
            width: obj.startWidth,
            height: obj.startHeight,
            scaleX: obj.scaleX,
            scaleY: obj.scaleY,
            scaleMode: obj.scaleMode
          })
          if (tmpInfo[obj.limitName] >= 20) {
            this.x = tmpInfo.rectX;
            this.width = tmpInfo.width;
            this.y = tmpInfo.rectY;
            this.height = tmpInfo.height;
            ctx.draw();
          }
        }

        // 将拖拽方法抽离出来
        dragMouseHandler(canvas) {
          const ctx = canvas.getContext("2d");
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          // 方法单列出来,方便后续注销事件
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (isMouseInSquare(mouseX, mouseY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
            }, this) && !isScaleing(canvas)) {
              lastSquareX = this.x;
              lastSquareY = this.y;
              canvas.style.cursor = "grabbing";
              this.isDragging = true;
              this.isSelected = true;
              // 当点击的对象不为最高层级时,将其挂载在最高层级
              if (ctx.level !== this.level) {
                ctx.drawItemList.forEach((item) => {
                  item.isDragging = false;
                  item.isSelected = false;
                  // 将之前比当前对象图层数高的对象减1
                  if (item.level > this.level) {
                    item.level--;
                  }
                });
                this.isDragging = true
                this.isSelected = true;
                this.level = ctx.level; // 提到最高层级
                // 重新注册鼠标事件,这个代表着执行顺序,最新注册的最后执行
                canvas.removeEventListener('mousedown', this.mousedownHandler)
                canvas.removeEventListener('mousemove', this.mousemoveHandler)
                canvas.addEventListener('mousedown', this.mousedownHandler)
                canvas.addEventListener('mousemove', this.mousemoveHandler)
              }
            } else {
              // 添加一些宽限方便边界拉伸
              if (!isMouseInSquare(mouseX, mouseY, {
                x: this.x - this.borderLineWidth - 5,
                y: this.y - this.borderLineWidth - 5,
                width: this.width + this.borderLineWidth * 2 + 10,
                height: this.height + this.borderLineWidth * 2 + 10,
              }, this) && !isScaleing(canvas)) {
                // console.log('不在方块内')
                this.isSelected = false;
                ctx.draw();
              }
            }
          }

          const mousemoveHandler = (e) => {
            if (this.isDragging) {
              canvas.style.cursor = "grabbing";
              const mouseX = e.offsetX;
              const mouseY = e.offsetY;
              offsetX = e.clientX - startX;
              offsetY = e.clientY - startY;
              this.x = lastSquareX + offsetX;
              this.y = lastSquareY + offsetY;
              ctx.draw();
            }
          }

          const mouseupHandler = (e) => {
            this.isDragging = false;
            lastSquareX = this.x;
            lastSquareY = this.y;
            offsetX = 0;
            offsetY = 0;
            canvas.style.cursor = "default";
          }

          return {
            dragMove: mousemoveHandler,
            dragDown: mousedownHandler,
            dragUp: mouseupHandler
          }
        }

        // 画选中后的框框
        drawBorderSquare(canvas, setting) {
          const ctx = canvas.getContext("2d");
          ctx.save();
          ctx.strokeStyle = '#51B9F9';
          ctx.lineWidth = setting.borderLineWidth; // 边框宽度
          ctx.strokeRect(this.x - Math.floor(setting.borderLineWidth / 2), this.y - Math.floor(setting.borderLineWidth / 2), this.width + setting.borderLineWidth, this.height + setting.borderLineWidth)
          // 恢复到之前的状态
          ctx.restore();

          // 画边框的5个圆
          this.drawCircle(ctx, this.x, this.y);
          this.drawCircle(ctx, this.x + this.width, this.y);
          this.drawCircle(ctx, this.x, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width / 2, this.y + this.height + 15);
        }

        // 画选中的5个圆
        drawCircle(ctx, x, y) {
          ctx.save();
          ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色
          ctx.shadowBlur = 5; // 阴影模糊级别
          ctx.shadowOffsetX = 0; // 阴影的水平偏移
          ctx.shadowOffsetY = 0; // 阴影的垂直偏移
          ctx.beginPath();
          ctx.fillStyle = 'white';
          ctx.arc(x, y, 6, 0, 2 * Math.PI);
          ctx.fill();
          ctx.restore();
        }

        // 判断鼠标是否在边框内
        isMouseInBorder(mouseX, mouseY, position) {
          if (position === 'left') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10
            }, this)
          } else if (position === 'right') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10,
            }, this)
          } else if (position === 'top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y - 5,
              width: this.width - 10,
              height: this.borderLineWidth + 5
            }, this)
          } else if (position === 'bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y + this.height - this.borderLineWidth,
              width: this.width - 10,
              height: this.borderLineWidth + 5,
            }, this)
          } else if (position === 'left-top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y - this.borderLineWidth - 5,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'right-top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y - 5,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'left-bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y + this.height - this.borderLineWidth,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'right-bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y + this.height - this.borderLineWidth,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          }
        }
      }

      const square1 = new mySquare(canvas, {
        x: 0,
        y: 0,
        width: 100,
        height: 100,
        color: "red"
      });
      const square2 = new mySquare(canvas, {
        x: 0,
        y: 0,
        width: 50,
        height: 50,
        color: "yellow"
      });

      const square3 = new mySquare(canvas, {
        x: 200,
        y: 0,
        width: 30,
        height: 50,
        color: "blue"
      });
    </script>
  </body>
</html>


recording.gif

5. 旋转功能

思路很简单,检测按下后鼠标位置相对于图形中心点的角度,然后旋转即可,旋转方法同样用上篇文章中的旋转方法:

新增旋转方法:

      /**
       * 中心点旋转
       * @param {CanvasRenderingContext2D} ctx canvas 2D 上下文
       * @param {Function} callback 绘制旋转矩形的回调函数
       * @param {Object} setting 旋转设置
       * @param {Number} setting.angle 旋转角度,弧度制
       */
      function rotateCenterPoint(ctx, setting, callback) {
        const { rectX, rectY, width, height, angle } = setting;
        ctx.save();
        ctx.translate(rectX + width / 2, rectY + height / 2); // 平移到 (100, 100)
        ctx.rotate(setting.angle); // 旋转 90 度
        ctx.translate(-(rectX + width / 2), -(rectY + height / 2)); // 平移回到原点
        if (callback) {
          callback(); // 绘制旋转矩形
        }
        ctx.restore(); // 恢复原始状态
      }

新增angle属性记录旋转角度:

 else if (this.isScaled === 'center-bottom') {
  // 中心点的坐标离鼠标的距离
  const tempX = mouseX - (this.x + this.width / 2);
  const tempY = mouseY - (this.y + this.height / 2);
  // 旋转的角度
  this.angle = Math.atan2(tempY, tempX) * 180 / Math.PI - 90;
  if (this.angle < 0) {
    this.angle = 360 + this.angle;
  }
  console.log('angleeeeeeee', this.angle)
  ctx.draw();
}

改造 ctx 的 draw 方法:

          // 添加ctx的统一绘画方法
          if (!ctx.draw) {
            ctx.draw = () => {
              ctx.clearRect(0, 0, canvas.width, canvas.height);
              for (let i = 0; i <= ctx.level; i++) {
                ctx.nowLevel = i;
                ctx.drawItemList.forEach((item) => {
                  // item.drawSquare(canvas);
                  // 新增旋转功能
                  rotateCenterPoint(ctx, {
                    rectX: item.x,
                    rectY: item.y,
                    width: item.width,
                    height: item.height,
                    angle: item.angle * Math.PI / 180,
                  }, item.drawSquare.bind(item, canvas));
                });
              }
            };
          }

效果:

recording.gif

6. 重构鼠标点检测方法

此时虽然方块可以旋转,但是在旋转后会发现很多问题,比如点击无法选中方块,边框点击错乱,这些都是因为旋转后方块的鼠标点检测方法没变导致的:

recording.gif

此处我采用将鼠标坐标沿着中心点反向旋转想通角度再判断

新增鼠标坐标转换方法

      /**
       * 将鼠标坐标点转换为旋转后的坐标点
       * @param {Number} mouseX 鼠标x坐标
       * @param {Number} mouseY 鼠标y坐标
       * @param {Object} squareSettings 方块设置
       * @param {Number} squareSettings.x 方块x坐标
       * @param {Number} squareSettings.y 方块y坐标
       * @param {Number} squareSettings.width 方块宽度
       * @param {Number} squareSettings.height 方块高度
       * @param {Number} squareSettings.angle 方块旋转角度(单位:度)
       */
      const changeMouseCoordinate = (mouseX, mouseY, squareSettings) => {
        const { x, y, width, height, angle } = squareSettings;
        // 方块中心点的坐标
        const centerX = x + width / 2;
        const centerY = y + height / 2;

        // 将角度转换为弧度
        const radian = (360 - angle) * (Math.PI / 180);

        // 将鼠标坐标转换为以中心点为原点的坐标
        const relativeX = mouseX - centerX;
        const relativeY = mouseY - centerY;

        // 计算旋转后的坐标
        const newRelativeX = relativeX * Math.cos(radian) - relativeY * Math.sin(radian);
        const newRelativeY = relativeX * Math.sin(radian) + relativeY * Math.cos(radian);

        // 将旋转后的坐标转换回原始坐标系
        const newMouseX = newRelativeX + centerX;
        const newMouseY = newRelativeY + centerY;

        return { newMouseX, newMouseY };
      }

在鼠标判断方法 isMouseInSquare 中使用新坐标:

      // 判断是否鼠标在方块内
      const isMouseInSquare = (mouseX, mouseY, squareSettings, Rect) => {
        const { newMouseX, newMouseY } = changeMouseCoordinate(mouseX, mouseY, Rect);
        return (
          newMouseX > squareSettings.x &&
          newMouseX < squareSettings.x + squareSettings.width &&
          newMouseY > squareSettings.y &&
          newMouseY < squareSettings.y + squareSettings.height
        );
      }

修改所有判断代码,新增参数:

image.png

recording.gif

此时已经可以正确的检测鼠标位置,鼠标按在方块外时不再能够拖动方块。

但是显而易见的是,拉伸在旋转后出问题了,原因在于拉伸过后再旋转,因为拉伸的缘故旋转中心变了,从而导致了方块的位置产生了偏移,接下来将解决这个问题。

7. 处理旋转后拉伸偏移问题

处理思路:记录拉伸前旋转后左上角的位置,再记录拉伸后旋转后的(同一点,不一定在左上角了)左上角位置,然后计算两者的差值,再用平移补偿偏移量。

注意:不同方向的拉伸记录的点是不一样的,就和拉伸方法一样,我们需要记录不动的那个点,再去计算偏移值。

  • 左上角点不动:右边框、下边框、右下角拉伸
  • 右上角点不动:左边框、下边框、左下角拉伸
  • 左下角点不动:右边框、上边框、右上角拉伸
  • 右下角点不动:左边框、上边框、左上角拉伸

此处列举未封装的下边框修改代码方便理解:

 else if (this.isScaled === 'bottom') {
                  // 下边框拉伸
                  const tmpInfo = scaleRect(ctx, {
                    rectX: this.x,
                    rectY: this.y,
                    width: startWidth,
                    height: startHeight,
                    scaleX: 1,
                    scaleY: (startHeight + offsetY) / startHeight,
                    scaleMode: 'left-top'
                  })
                  if (tmpInfo.height >= 20) {
                    this.x = tmpInfo.rectX;
                    this.width = tmpInfo.width;
                    this.y = tmpInfo.rectY;
                    this.height = tmpInfo.height;

                    // 新增:记录拉伸前旋转后的x坐标和y坐标
                    let beforeObj = changeMouseCoordinate(startRectX, startRectY, {
                      x: startRectX,
                      y: startRectY,
                      width: startWidth,
                      height: startHeight,
                      angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
                    })
                    // 新增:记录拉伸后旋转后的x坐标和y坐标
                    let afterObj = changeMouseCoordinate(this.x, this.y, {
                      x: this.x,
                      y: this.y,
                      width: this.width,
                      height: this.height,
                      angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
                    })
                    // 新增:记录偏移量
                    const translateX = afterObj.newMouseX - beforeObj.newMouseX;
                    const translateY = afterObj.newMouseY - beforeObj.newMouseY;

                    // 将偏移量加给坐标
                    this.x = this.x - translateX;
                    this.y = this.y - translateY;

                    ctx.draw();
                  }
                }

封装后的 scaleHelpFunc 方法:

        // 缩放的重复代码太多,抽离出来
        scaleHelpFunc = (obj) => {
          const tmpInfo = scaleRect(ctx, {
            rectX: obj.startRectX,
            rectY: obj.startRectY,
            width: obj.startWidth,
            height: obj.startHeight,
            scaleX: obj.scaleX,
            scaleY: obj.scaleY,
            scaleMode: obj.scaleMode
          })
          if (tmpInfo[obj.limitName] >= 20) {
            this.x = tmpInfo.rectX;
            this.width = tmpInfo.width;
            this.y = tmpInfo.rectY;
            this.height = tmpInfo.height;

            let beforeX = obj.startRectX;
            let beforeY = obj.startRectY;
            let afterX = this.x;
            let afterY = this.y;

            if (obj.scaleMode === 'left-top') {
              // 左上角点不动
              beforeX = obj.startRectX;
              beforeY = obj.startRectY;
              afterX = this.x;
              afterY = this.y;
            } else if (obj.scaleMode === 'right-top') {
              // 右上角点不动
              beforeX = obj.startRectX + obj.startWidth;
              beforeY = obj.startRectY;
              afterX = this.x + this.width;
              afterY = this.y;
            } else if (obj.scaleMode === 'left-bottom') {
              // 左下角点不动
              beforeX = obj.startRectX;
              beforeY = obj.startRectY + obj.startHeight;
              afterX = this.x;
              afterY = this.y + this.height;
            } else if (obj.scaleMode === 'right-bottom') {
              // 右下角点不动
              beforeX = obj.startRectX + obj.startWidth;
              beforeY = obj.startRectY + obj.startHeight;
              afterX = this.x + this.width;
              afterY = this.y + this.height;
            }

            // 新增:记录拉伸前旋转后的x坐标和y坐标
            let beforeObj = changeMouseCoordinate(beforeX, beforeY, {
              x: obj.startRectX,
              y: obj.startRectY,
              width: obj.startWidth,
              height: obj.startHeight,
              angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
            })
            // 新增:记录拉伸后旋转后的x坐标和y坐标
            let afterObj = changeMouseCoordinate(afterX, afterY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
              angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
            })
            // 新增:记录偏移量
            const translateX = afterObj.newMouseX - beforeObj.newMouseX;
            const translateY = afterObj.newMouseY - beforeObj.newMouseY;

            // 将偏移量加给坐标
            this.x = this.x - translateX;
            this.y = this.y - translateY;

            ctx.draw();
          }
        }

旋转后的鼠标指针全乱了,此处添加修改(偷了个懒,有兴趣的自己改):

        // 旋转过后的鼠标指针全乱了,这里重新计算
        changeMouseCursor(setting) {
          const canvas = this.canvas;
          const { mouseX, mouseY } = setting;
          const centerX = this.x + this.width / 2;
          const centerY = this.y + this.height / 2;
          if (mouseX < centerX && mouseY < centerY) {
            canvas.style.cursor = "nw-resize";
          } else if (mouseX > centerX && mouseY < centerY) {
            canvas.style.cursor = "ne-resize";
          } else if (mouseX < centerX && mouseY > centerY) {
            canvas.style.cursor = "sw-resize";
          } else if (mouseX > centerX && mouseY > centerY) {
            canvas.style.cursor = "se-resize";
          } else if (mouseX === centerX && mouseY < centerY) {
            canvas.style.cursor = "n-resize";
          } else if (mouseX === centerX && mouseY > centerY) {
            canvas.style.cursor = "s-resize";
          } else if (mouseX < centerX && mouseY === centerY) {
            canvas.style.cursor = "w-resize";
          } else if (mouseX > centerX && mouseY === centerY) {
            canvas.style.cursor = "e-resize";
          } else if (mouseX === centerX && mouseY === centerY) {
            canvas.style.cursor = "move";
          }
        }

效果图:

recording.gif

此时还有很多能优化的点,比如旋转后拖拽判断不应该是单独的xy偏移,而是旋转后的方向偏移,但是整体功能大差不差所以我就不改了,有兴趣可以自行尝试修改。

封装复用

1. 画圆

canvas 自带的 arc 方法只能画正圆,要想实现椭圆功能需要自己封装下。而且由于封装好的 mySquare 类的主要属性为 x, y , width , height , color 所以封装的椭圆方法也需要用这些参数画圆。

新增画圆方法 drawCircle:

      function drawCircle(ctx, setting) {
        const { x, y, width, height, color = 'blue', rotation = 0, startAngle = 0, endAngle = 2 * Math.PI, anticlockwise = false } = setting;
        // 保存当前的绘图状态
        ctx.save();
        // 移动到椭圆的中心
        ctx.translate(x + width / 2, y + height / 2);
        // 缩放绘图
        ctx.scale(width / 2, height / 2);
        // 绘制椭圆
        ctx.beginPath();
        ctx.arc(0, 0, 1, startAngle, endAngle, anticlockwise);

        // 设置线条样式并绘制
        ctx.fillStyle = color;
        ctx.fill();
        ctx.restore();
      }

复用 mySquare 类,直接继承重写 drawSquare 方法, 将画方块的代码改成画圆的即可:

      class myCircle extends mySquare {
        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            drawCircle(ctx, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
              color: this.color
            });
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }
      }
      
      const circle1 = new myCircle(canvas, {
        x: 0,
        y: 0,
        width: 100,
        height: 100,
        color: "yellow",
      })

效果:

recording.gif

2. 画自定义图片

原理和画圆一样,canvas提供了 drawImage 方法绘制图片,该方法还可以裁剪图片。

需要注意的是,由于画图片必须要用到图片,也就是说多了一个参数,所以我们还需要重写下 constructor 方法。

先修改 mySquare 父类的 constructor 方法,新增isDraw参数

        constructor(canvas, squareSettings, isDraw = true) {
          const ctx = canvas.getContext("2d");
          this.x = squareSettings.x; // 方块的x坐标
          this.y = squareSettings.y; // 方块的y坐标
          this.width = squareSettings.width; // 方块的宽度
          this.height = squareSettings.height; // 方块的高度
          this.color = squareSettings.color; // 方块的颜色
          this.canvas = canvas; // 画布

          this.squareHandler(canvas); // 注册方块的鼠标点击移动等事件

          this.ctxDraw(canvas); // 注册ctx的统一绘画方法

          // 新增参数,方便子类继承添加属性
          if (isDraw) {
            ctx.draw()
          }
        }

新增 myImage 类:


      class myImage extends mySquare {
        // 重写constructor方法
        constructor(canvas, squareSettings) {
          super(canvas, squareSettings, false);
          const ctx = canvas.getContext("2d");
          this.img = squareSettings.img;
          ctx.draw(); // 绘制所有图层
        }
        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }
      }

      const img = document.querySelector('#img');
      const image1 = new myImage(canvas, {
        x: 300,
        y: 300,
        width: 100,
        height: 100,
        img: img
      })

效果:

recording.gif

其他图形的原理都是一样的,此处不再一一列举,有兴趣的可以自己尝试。

项目完整html代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      #canvas {
        border: 1px solid black;
      }
      img {
        display: none;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <img id="img" src="https://p3-passport.byteacctimg.com/img/user-avatar/cf1360aaf487985ef6416ae977d3ccca~90x90.awebp" alt="">
    <script>
      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");

      // 判断是否鼠标在方块内
      const isMouseInSquare = (mouseX, mouseY, squareSettings, Rect) => {
        const { newMouseX, newMouseY } = changeMouseCoordinate(mouseX, mouseY, Rect);
        // const newMouseX = mouseX;
        // const newMouseY = mouseY;
        return (
          newMouseX > squareSettings.x &&
          newMouseX < squareSettings.x + squareSettings.width &&
          newMouseY > squareSettings.y &&
          newMouseY < squareSettings.y + squareSettings.height
        );
      }
      

      /**
       * 将鼠标坐标点转换为旋转后的坐标点
       * @param {Number} mouseX 鼠标x坐标
       * @param {Number} mouseY 鼠标y坐标
       * @param {Object} squareSettings 方块设置
       * @param {Number} squareSettings.x 方块x坐标
       * @param {Number} squareSettings.y 方块y坐标
       * @param {Number} squareSettings.width 方块宽度
       * @param {Number} squareSettings.height 方块高度
       * @param {Number} squareSettings.angle 方块旋转角度(单位:度)
       */
      const changeMouseCoordinate = (mouseX, mouseY, squareSettings) => {
        const { x, y, width, height, angle } = squareSettings;
        // 方块中心点的坐标
        const centerX = x + width / 2;
        const centerY = y + height / 2;

        // 将角度转换为弧度
        const radian = (360 - angle) * (Math.PI / 180);

        // 将鼠标坐标转换为以中心点为原点的坐标
        const relativeX = mouseX - centerX;
        const relativeY = mouseY - centerY;

        // 计算旋转后的坐标
        const newRelativeX = relativeX * Math.cos(radian) - relativeY * Math.sin(radian);
        const newRelativeY = relativeX * Math.sin(radian) + relativeY * Math.cos(radian);

        // 将旋转后的坐标转换回原始坐标系
        const newMouseX = newRelativeX + centerX;
        const newMouseY = newRelativeY + centerY;

        return { newMouseX, newMouseY };
      }

      /**
       * 判断是否鼠标是拉伸模式
       */
      function isScaleing(canvas) {
        if (canvas.style.cursor === 'n-resize' || canvas.style.cursor === 'e-resize' || canvas.style.cursor === 's-resize' || canvas.style.cursor === 'w-resize' || canvas.style.cursor === 'ew-resize' || canvas.style.cursor === 'ns-resize' || canvas.style.cursor === 'nesw-resize' || canvas.style.cursor === 'nwse-resize' || canvas.style.cursor === 'col-resize' || canvas.style.cursor === 'all-scroll' || canvas.style.cursor === 'move' || canvas.style.cursor === 'nw-resize' || canvas.style.cursor === 'ne-resize' || canvas.style.cursor === 'ne-resize' || canvas.style.cursor === 'nesw-resize' || canvas.style.cursor === 'ns-resize' || canvas.style.cursor === 'nwse-resize') {
          return true;
        } else {
          return false;
        }
      }

      /**
       * 中心点旋转
       * @param {CanvasRenderingContext2D} ctx canvas 2D 上下文
       * @param {Function} callback 绘制旋转矩形的回调函数
       * @param {Object} setting 旋转设置
       * @param {Number} setting.angle 旋转角度,弧度制
       */
      function rotateCenterPoint(ctx, setting, callback) {
        const { rectX, rectY, width, height, angle = 0, translateX = 0, translateY = 0} = setting;
        ctx.save();
        // ctx.translate(-translateX, -translateY); // 补偿偏移量
        ctx.translate(rectX + width / 2, rectY + height / 2); // 平移到 (100, 100)
        ctx.rotate(setting.angle); // 旋转 90 度
        ctx.translate(-(rectX + width / 2), -(rectY + height / 2)); // 平移回到原点
        if (callback) {
          callback(); // 绘制旋转矩形
        }
        ctx.restore(); // 恢复原始状态
      }

      /**
       * 缩放功能
       * callback 绘制旋转矩形的函数
       * mode 从什么地方缩放
       * setting.rectX 矩形 x 坐标
       * setting.rectY 矩形 y 坐标
       * setting.width 矩形宽度
       * setting.height 矩形高度
       * setting.scaleX 缩放比例 x
       * setting.scaleY 缩放比例 y
       */
      function scaleRect(ctx, setting, callback) {
        const { rectX, rectY, width, height, scaleX, scaleY, scaleMode } = setting;
        ctx.save();
        ctx.translate(rectX, rectY); // 平移到 (0, 0)
        ctx.scale(scaleX, scaleY);
        let translateX = 0;
        let translateY = 0;
        if (scaleMode === 'left-top') {
          // 左上角点固定的缩放
        } else if (scaleMode == 'right-top') {
          // 右上角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
        } else if (scaleMode == 'left-bottom') {
          // 左下角点固定的缩放
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        } else if (scaleMode == 'right-bottom') {
          // 右下角点固定的缩放
          translateX = (width - width * scaleX) / scaleX; // 补偿偏移量
          translateY = (height - height * scaleY) / scaleY; // 补偿偏移量
        }
        ctx.translate(translateX, translateY);
        ctx.translate(-rectX, -rectY); // 平移回到原点
        if (callback) {
          callback();
        }
        ctx.restore(); // 恢复原始状态
        return {
          rectX: rectX + translateX * scaleX,
          rectY: rectY + translateY * scaleY,
          width: width * scaleX,
          height: height * scaleY,
        }
      }


      function drawCircle(ctx, setting) {
        const { x, y, width, height, color = 'blue', rotation = 0, startAngle = 0, endAngle = 2 * Math.PI, anticlockwise = false } = setting;
        // 保存当前的绘图状态
        ctx.save();
        // 移动到椭圆的中心
        ctx.translate(x + width / 2, y + height / 2);
        // 缩放绘图
        ctx.scale(width / 2, height / 2);
        // 绘制椭圆
        ctx.beginPath();
        ctx.arc(0, 0, 1, startAngle, endAngle, anticlockwise);

        // 设置线条样式并绘制
        ctx.fillStyle = color;
        ctx.fill();
        ctx.restore();
      }
      class mySquare {
        isDragging = false; // 方块是否被拖拽
        isSelected = false; // 方块是否被选中
        isScaled = false; // 方块是否正在被拉伸
        borderLineWidth = 2;
        angle = 0; // 旋转角度
        translateX = 0; // 旋转后的补偿偏移量
        translateY = 0; // 旋转后的补偿偏移量
        constructor(canvas, squareSettings, isDraw = true) {
          const ctx = canvas.getContext("2d");
          this.x = squareSettings.x; // 方块的x坐标
          this.y = squareSettings.y; // 方块的y坐标
          this.width = squareSettings.width; // 方块的宽度
          this.height = squareSettings.height; // 方块的高度
          this.color = squareSettings.color; // 方块的颜色
          this.canvas = canvas; // 画布

          this.squareHandler(canvas); // 注册方块的鼠标点击移动等事件

          this.ctxDraw(canvas); // 注册ctx的统一绘画方法

          // 新增参数,方便子类继承添加属性
          if (isDraw) {
            ctx.draw()
          }
        }

        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.save();
            ctx.fillStyle = this.color;
            // ctx.translate(-this.translateX, -this.translateY)
            ctx.fillRect(this.x, this.y, this.width, this.height);
            ctx.restore();
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }

        // 将图层挂载在ctx上再统一绘制
        ctxDraw(canvas) {
          const ctx = canvas.getContext("2d");
          // 将图层挂载在ctx上
          if (!ctx.level) {
            ctx.level = 0;
          }
          ctx.level++; // 图层数加1
          this.level = ctx.level; // 记录此对象的图层数

          // 存储每一个新增的mySquare对象
          if (!ctx.drawItemList) {
            ctx.drawItemList = [];
          }
          ctx.drawItemList.push(this);

          // 添加ctx的统一绘画方法
          if (!ctx.draw) {
            ctx.draw = () => {
              ctx.clearRect(0, 0, canvas.width, canvas.height);
              for (let i = 0; i <= ctx.level; i++) {
                ctx.nowLevel = i;
                ctx.drawItemList.forEach((item) => {
                  // item.drawSquare(canvas);
                  // 新增旋转功能
                  rotateCenterPoint(ctx, {
                    rectX: item.x,
                    rectY: item.y,
                    width: item.width,
                    height: item.height,
                    translateX: item.translateX,
                    translateY: item.translateY,
                    angle: item.angle * Math.PI / 180,
                  }, item.drawSquare.bind(item, canvas));
                });
              }
            };
          }
        }

        // 方块的鼠标点击移动等事件
        squareHandler(canvas) {
          const ctx = canvas.getContext("2d");

          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          const { dragMove, dragDown, dragUp } = this.dragMouseHandler(canvas);
          const { scaleMouseDown, scaleMouseMove, scaleMouseUp } = this.scaleMouseHandler(canvas);

          // 方法单列出来,方便后续注销事件
          this.mousedownHandler = (e) => {
            dragDown(e);
            scaleMouseDown(e);
          }

          this.mousemoveHandler = (e) => {
            dragMove(e);
            scaleMouseMove(e);
          }

          this.mouseupHandler = (e) => {
            dragUp(e);
            scaleMouseUp(e);
          }

          // 注册鼠标事件
          canvas.addEventListener("mousedown", this.mousedownHandler);
          canvas.addEventListener("mousemove", this.mousemoveHandler);
          canvas.addEventListener("mouseup", this.mouseupHandler);
          canvas.addEventListener("mouseleave", this.mouseupHandler);
        }

        // 边框和四角拉伸功能 旋转功能
        scaleMouseHandler(canvas) {
          const ctx = canvas.getContext("2d");
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量
          let startWidth, startHeight; // 记录鼠标按下时方块的宽度和高度
          let startRectX, startRectY; // 记录鼠标按下时方块的坐标
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            offsetX = 0;
            offsetY = 0;
            startWidth = this.width;
            startHeight = this.height;
            startRectX = this.x;
            startRectY = this.y;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left')) {
                // 检测是否在左边框内
                this.isScaled = 'left';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right')) {
                // 检测是否在右边框内
                this.isScaled = 'right';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top')) {
                // 检测是否在上边框内
                this.isScaled = 'top';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom')) {
                // 检测是否在上边框内
                this.isScaled = 'bottom';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-top')) {
                // 检测是否在左上角
                this.isScaled = 'left-top';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-bottom')) {
                // 检测是否在左下角
                this.isScaled = 'left-bottom';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-top')) {
                // 检测是否在右上角
                this.isScaled = 'right-top';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-bottom')) {
                // 检测是否在右下角
                this.isScaled = 'right-bottom';
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'center-bottom')) {
                // 检测是否在下方
                this.isScaled = 'center-bottom';
                canvas.style.cursor = "all-scroll";
              }
            }
          }

          const mousemoveHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            if (this.isSelected) {
              if (this.isMouseInBorder(mouseX, mouseY, 'left') && !this.isScaled) {
                // 检测是否在左边框内
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right') && !this.isScaled) {
                // 检测是否在右边框内
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'top') && !this.isScaled) {
                // 检测是否在上边框内
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'bottom') && !this.isScaled) {
                // 检测是否在下边框内
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-top') && !this.isScaled) {
                // 检测是否在左上角
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'left-bottom') && !this.isScaled) {
                // 检测是否在左下角
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-top') && !this.isScaled) {
                // 检测是否在右上角
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'right-bottom') && !this.isScaled) {
                // 检测是否在右下角
                this.changeMouseCursor({ mouseX, mouseY });
              } else if (this.isMouseInBorder(mouseX, mouseY, 'center-bottom') && !this.isScaled) {
                // 检测是否在右下角
                canvas.style.cursor = "all-scroll";
              } else if (!this.isDragging && !this.isScaled ) {
                // 不在边框内时,且不在拉伸和拖拽状态时,回复鼠标指针
                canvas.style.cursor = "default";
              }

              if (this.isScaled) {
                // 边框拉伸功能
                if (this.angle >= 90 && this.angle <= 270) {
                  offsetY = -e.clientY + startY;
                  offsetX = -e.clientX + startX;
                } else {
                  offsetY = e.clientY - startY;
                  offsetX = e.clientX - startX;
                }

                const helpObj = {
                  startRectX: startRectX,
                  startRectY: startRectY,
                  startWidth: startWidth,
                  startHeight: startHeight,
                  scaleX: 1,
                  scaleY: 1,
                  scaleMode: 'left-top',
                  limitName: 'width'
                }
                if (this.isScaled === 'left') {
                  // 左边框拉伸
                  helpObj.scaleMode = 'right-top';
                  helpObj.scaleX = (startWidth - offsetX) / startWidth;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'right') {
                  // 右边框拉伸
                  helpObj.scaleMode = 'left-top';
                  helpObj.scaleX = (startWidth + offsetX) / startWidth;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'top') {
                  // 上边框拉伸
                  helpObj.limitName = 'height';
                  helpObj.scaleMode = 'left-bottom';
                  helpObj.scaleY = (startHeight - offsetY) / startHeight;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'bottom') {
                  // 下边框拉伸
                  helpObj.limitName = 'height';
                  helpObj.scaleMode = 'left-top';
                  helpObj.scaleY = (startHeight + offsetY) / startHeight;
                  this.scaleHelpFunc(helpObj)
                } else if (this.isScaled === 'left-top') {
                  const tempScale = (startWidth - offsetX) / startWidth > (startHeight - offsetY) / startHeight ? (startWidth - offsetX) / startWidth : (startHeight - offsetY) / startHeight;
                  // 左上角拉伸
                  helpObj.scaleMode = 'right-bottom';
                  helpObj.scaleX = tempScale;
                  helpObj.scaleY = tempScale;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'left-bottom') {
                  const tempScale = (startWidth - offsetX) / startWidth > (startHeight + offsetY) / startHeight ? (startWidth - offsetX) / startWidth : (startHeight + offsetY) / startHeight;
                  // 左下角拉伸
                  helpObj.limitName = 'height';
                  helpObj.scaleMode = 'right-top';
                  helpObj.scaleX = tempScale;
                  helpObj.scaleY = tempScale;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'right-top') {
                  const tempScale = (startWidth + offsetX) / startWidth > (startHeight - offsetY) / startHeight ? (startWidth + offsetX) / startWidth : (startHeight - offsetY) / startHeight;
                  // 右上角拉伸
                  helpObj.limitName = 'height';
                  helpObj.scaleMode = 'left-bottom';
                  helpObj.scaleX = tempScale;
                  helpObj.scaleY = tempScale;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'right-bottom') {
                  const tempScale = (startWidth + offsetX) / startWidth > (startHeight + offsetY) / startHeight ? (startWidth + offsetX) / startWidth : (startHeight + offsetY) / startHeight;
                  // 右下角拉伸
                  helpObj.limitName = 'height';
                  helpObj.scaleMode = 'left-top';
                  helpObj.scaleX = tempScale;
                  helpObj.scaleY = tempScale;
                  this.scaleHelpFunc(helpObj);
                } else if (this.isScaled === 'center-bottom') {
                  // 中心点的坐标离鼠标的距离
                  const tempX = mouseX - (this.x + this.width / 2);
                  const tempY = mouseY - (this.y + this.height / 2);
                  // 旋转的角度
                  this.angle = Math.atan2(tempY, tempX) * 180 / Math.PI - 90;
                  if (this.angle < 0) {
                    this.angle = 360 + this.angle;
                  }
                  ctx.draw();
                }
              }
            }
          }

          const mouseupHandler = (e) => {
            this.isScaled = false;
          }

          return {
            scaleMouseDown: mousedownHandler,
            scaleMouseMove: mousemoveHandler,
            scaleMouseUp: mouseupHandler
          }
        }

        // 旋转过后的鼠标指针全乱了,这里重新计算
        changeMouseCursor(setting) {
          const canvas = this.canvas;
          const { mouseX, mouseY } = setting;
          const centerX = this.x + this.width / 2;
          const centerY = this.y + this.height / 2;
          if (mouseX < centerX && mouseY < centerY) {
            canvas.style.cursor = "nw-resize";
          } else if (mouseX > centerX && mouseY < centerY) {
            canvas.style.cursor = "ne-resize";
          } else if (mouseX < centerX && mouseY > centerY) {
            canvas.style.cursor = "sw-resize";
          } else if (mouseX > centerX && mouseY > centerY) {
            canvas.style.cursor = "se-resize";
          } else if (mouseX === centerX && mouseY < centerY) {
            canvas.style.cursor = "n-resize";
          } else if (mouseX === centerX && mouseY > centerY) {
            canvas.style.cursor = "s-resize";
          } else if (mouseX < centerX && mouseY === centerY) {
            canvas.style.cursor = "w-resize";
          } else if (mouseX > centerX && mouseY === centerY) {
            canvas.style.cursor = "e-resize";
          } else if (mouseX === centerX && mouseY === centerY) {
            canvas.style.cursor = "move";
          }
        }

        // 缩放的重复代码太多,抽离出来
        scaleHelpFunc = (obj) => {
          const tmpInfo = scaleRect(ctx, {
            rectX: obj.startRectX,
            rectY: obj.startRectY,
            width: obj.startWidth,
            height: obj.startHeight,
            scaleX: obj.scaleX,
            scaleY: obj.scaleY,
            scaleMode: obj.scaleMode
          })
          if (tmpInfo[obj.limitName] >= 20) {
            this.x = tmpInfo.rectX;
            this.width = tmpInfo.width;
            this.y = tmpInfo.rectY;
            this.height = tmpInfo.height;

            let beforeX = obj.startRectX;
            let beforeY = obj.startRectY;
            let afterX = this.x;
            let afterY = this.y;

            if (obj.scaleMode === 'left-top') {
              // 左上角点不动
              beforeX = obj.startRectX;
              beforeY = obj.startRectY;
              afterX = this.x;
              afterY = this.y;
            } else if (obj.scaleMode === 'right-top') {
              // 右上角点不动
              beforeX = obj.startRectX + obj.startWidth;
              beforeY = obj.startRectY;
              afterX = this.x + this.width;
              afterY = this.y;
            } else if (obj.scaleMode === 'left-bottom') {
              // 左下角点不动
              beforeX = obj.startRectX;
              beforeY = obj.startRectY + obj.startHeight;
              afterX = this.x;
              afterY = this.y + this.height;
            } else if (obj.scaleMode === 'right-bottom') {
              // 右下角点不动
              beforeX = obj.startRectX + obj.startWidth;
              beforeY = obj.startRectY + obj.startHeight;
              afterX = this.x + this.width;
              afterY = this.y + this.height;
            }

            // 新增:记录拉伸前旋转后的x坐标和y坐标
            let beforeObj = changeMouseCoordinate(beforeX, beforeY, {
              x: obj.startRectX,
              y: obj.startRectY,
              width: obj.startWidth,
              height: obj.startHeight,
              angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
            })
            // 新增:记录拉伸后旋转后的x坐标和y坐标
            let afterObj = changeMouseCoordinate(afterX, afterY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
              angle: 360 - this.angle, // 注意鼠标转化方法这个是反的,所以得再反一下
            })
            // 新增:记录偏移量
            const translateX = afterObj.newMouseX - beforeObj.newMouseX;
            const translateY = afterObj.newMouseY - beforeObj.newMouseY;

            // 将偏移量加给坐标
            this.x = this.x - translateX;
            this.y = this.y - translateY;

            ctx.draw();
          }
        }

        // 将拖拽方法抽离出来
        dragMouseHandler(canvas) {
          const ctx = canvas.getContext("2d");
          let startX, startY; // 记录鼠标按下时鼠标的坐标
          let lastSquareX = this.x, lastSquareY = this.y; // 记录每次按下时方块的起始坐标
          let offsetX = 0, offsetY = 0; // 记录每次按下后的偏移量

          // 方法单列出来,方便后续注销事件
          const mousedownHandler = (e) => {
            const mouseX = e.offsetX;
            const mouseY = e.offsetY;
            startX = e.clientX - offsetX;
            startY = e.clientY - offsetY;
            if (isMouseInSquare(mouseX, mouseY, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
            }, this) && !isScaleing(canvas)) {
              lastSquareX = this.x;
              lastSquareY = this.y;
              canvas.style.cursor = "grabbing";
              this.isDragging = true;
              this.isSelected = true;
              // 当点击的对象不为最高层级时,将其挂载在最高层级
              if (ctx.level !== this.level) {
                ctx.drawItemList.forEach((item) => {
                  item.isDragging = false;
                  item.isSelected = false;
                  // 将之前比当前对象图层数高的对象减1
                  if (item.level > this.level) {
                    item.level--;
                  }
                });
                this.isDragging = true
                this.isSelected = true;
                this.level = ctx.level; // 提到最高层级
                // 重新注册鼠标事件,这个代表着执行顺序,最新注册的最后执行
                canvas.removeEventListener('mousedown', this.mousedownHandler)
                canvas.removeEventListener('mousemove', this.mousemoveHandler)
                canvas.addEventListener('mousedown', this.mousedownHandler)
                canvas.addEventListener('mousemove', this.mousemoveHandler)
              }
            } else {
              // 添加一些宽限方便边界拉伸
              if (!isMouseInSquare(mouseX, mouseY, {
                x: this.x - this.borderLineWidth - 5,
                y: this.y - this.borderLineWidth - 5,
                width: this.width + this.borderLineWidth * 2 + 10,
                height: this.height + this.borderLineWidth * 2 + 10,
              }, this) && !isScaleing(canvas)) {
                // console.log('不在方块内')
                this.isSelected = false;
                ctx.draw();
              }
            }
          }

          const mousemoveHandler = (e) => {
            if (this.isDragging) {
              canvas.style.cursor = "grabbing";
              const mouseX = e.offsetX;
              const mouseY = e.offsetY;
              offsetX = e.clientX - startX;
              offsetY = e.clientY - startY;
              this.x = lastSquareX + offsetX;
              this.y = lastSquareY + offsetY;
              ctx.draw();
            }
          }

          const mouseupHandler = (e) => {
            this.isDragging = false;
            lastSquareX = this.x;
            lastSquareY = this.y;
            offsetX = 0;
            offsetY = 0;
            canvas.style.cursor = "default";
          }

          return {
            dragMove: mousemoveHandler,
            dragDown: mousedownHandler,
            dragUp: mouseupHandler
          }
        }

        // 画选中后的框框
        drawBorderSquare(canvas, setting) {
          const ctx = canvas.getContext("2d");
          ctx.save();
          ctx.strokeStyle = '#51B9F9';
          ctx.lineWidth = setting.borderLineWidth; // 边框宽度
          ctx.strokeRect(this.x - Math.floor(setting.borderLineWidth / 2), this.y - Math.floor(setting.borderLineWidth / 2), this.width + setting.borderLineWidth, this.height + setting.borderLineWidth)
          // 恢复到之前的状态
          ctx.restore();

          // 画边框的5个圆
          this.drawCircle(ctx, this.x, this.y);
          this.drawCircle(ctx, this.x + this.width, this.y);
          this.drawCircle(ctx, this.x, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width, this.y + this.height);
          this.drawCircle(ctx, this.x + this.width / 2, this.y + this.height + 15);
        }

        // 画选中的5个圆
        drawCircle(ctx, x, y, color) {
          ctx.save();
          ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色
          ctx.shadowBlur = 5; // 阴影模糊级别
          ctx.shadowOffsetX = 0; // 阴影的水平偏移
          ctx.shadowOffsetY = 0; // 阴影的垂直偏移
          ctx.beginPath();
          ctx.fillStyle = 'white';
          if (color) {
            ctx.fillStyle = color;
          }
          ctx.arc(x, y, 6, 0, 2 * Math.PI);
          ctx.fill();
          ctx.restore();
        }

        // 判断鼠标是否在边框内
        isMouseInBorder(mouseX, mouseY, position) {
          if (position === 'left') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10
            }, this)
          } else if (position === 'right') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y + 5,
              width: this.borderLineWidth + 5,
              height: this.height - 10,
            }, this)
          } else if (position === 'top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y - 5,
              width: this.width - 10,
              height: this.borderLineWidth + 5
            }, this)
          } else if (position === 'bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + 5,
              y: this.y + this.height - this.borderLineWidth,
              width: this.width - 10,
              height: this.borderLineWidth + 5,
            }, this)
          } else if (position === 'left-top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y - this.borderLineWidth - 5,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'right-top') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y - 5,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'left-bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth - 5,
              y: this.y + this.height - this.borderLineWidth,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'right-bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x - this.borderLineWidth + this.width,
              y: this.y + this.height - this.borderLineWidth,
              width: this.borderLineWidth + 10,
              height: this.borderLineWidth + 10,
            }, this)
          } else if (position === 'center-bottom') {
            return isMouseInSquare(mouseX, mouseY, {
              x: this.x + this.width / 2 - 10,
              y: this.y + this.height + 2,
              width: 20,
              height: 20,
            }, this)
          }
        }
      }

      class myCircle extends mySquare {
        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            drawCircle(ctx, {
              x: this.x,
              y: this.y,
              width: this.width,
              height: this.height,
              color: this.color
            });
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }
      }

      class myImage extends mySquare {
        // 重写constructor方法
        constructor(canvas, squareSettings) {
          super(canvas, squareSettings, false);
          const ctx = canvas.getContext("2d");
          this.img = squareSettings.img;
          ctx.draw(); // 绘制所有图层
        }
        // 绘制方块
        drawSquare(canvas) {
          const ctx = canvas.getContext("2d");
          // 当在本图层绘制时才绘制
          if (this.level === ctx.nowLevel) {
            ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
            // 当被选中后画边框
            if (this.isSelected) {
              this.drawBorderSquare(canvas, {
                borderLineWidth: this.borderLineWidth
              });
            }
          }
        }
      }

      const img = document.querySelector('#img');
      const image1 = new myImage(canvas, {
        x: 300,
        y: 300,
        width: 100,
        height: 100,
        img: img
      })
      const circle1 = new myCircle(canvas, {
        x: 0,
        y: 0,
        width: 100,
        height: 100,
        color: "yellow",
      })

      const square1 = new mySquare(canvas, {
        x: 100,
        y: 0,
        width: 100,
        height: 100,
        color: "red"
      });
      const square2 = new mySquare(canvas, {
        x: 100,
        y: 100,
        width: 100,
        height: 100,
        color: "blue"
      });

    </script>
  </body>
</html>

结尾

看似简单的功能,修改起来有很多复杂的坑,虽然是一次无意义的重复造轮子工程,但是在编程中学习了很多。个人水平有限,欢迎提出问题和建议,点赞多的话会考虑出一些其他功能的开发思路。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐