我做了一个在线白板!

举报
街角小林 发表于 2022/10/24 21:31:24 2022/10/24
【摘要】 相信各位写文章的朋友平时肯定都有画图的需求,笔者平时用的是一个在线的手绘风格白板–excalidraw,使用体验上没的说,但是有一个问题,不能云端保存,不过好消息它是开源的,所以笔者就在想要不要基于它做一个支持云端保存的,于是三下两除二写了几个接口就完成了,虽然功能完成了,但是坏消息是excalidraw是基于React的,而且代码量很庞大,对于笔者这种常年写Vue的人来说不是很友好,另外也...

相信各位写文章的朋友平时肯定都有画图的需求,笔者平时用的是一个在线的手绘风格白板–excalidraw,使用体验上没的说,但是有一个问题,不能云端保存,不过好消息它是开源的,所以笔者就在想要不要基于它做一个支持云端保存的,于是三下两除二写了几个接口就完成了,虽然功能完成了,但是坏消息是excalidraw是基于React的,而且代码量很庞大,对于笔者这种常年写Vue的人来说不是很友好,另外也无法在Vue项目上使用,于是闲着也是闲着,笔者就花了差不多一个月的业余时间来做了一个草率版的,框架无关,先来一睹为快:

board.gif

也可体验在线demohttps://wanglin2.github.io/tiny_whiteboard_demo/

源码仓库在此:https://github.com/wanglin2/tiny_whiteboard

接下来笔者就来大致介绍一下实现的关键技术点。

本文的配图均使用笔者开发的白板进行绘制。

简单起见,我们以【一个矩形的一生】来看一下大致的整个流程实现。

出生

矩形即将出生的是一个叫做canvas的画布世界,这个世界大致是这样的:

<template>
  <div class="container">
    <div class="canvasBox" ref="box"></div>
  </div>
</template>

<script setup>
    import { onMounted, ref } from "vue";

    const container = ref(null);
    const canvas = ref(null);
    let ctx = null;
    const initCanvas = () => {
        let { width, height } = container.value.getBoundingClientRect();
        canvas.value.width = width;
        canvas.value.height = height;
        ctx = canvas.value.getContext("2d");
        // 将画布的原点由左上角移动到中心点
        ctx.translate(width / 2, height / 2);
    };

    onMounted(() => {
        initCanvas();
    });
</script>

为什么要将画布世界的原点移动到中心呢,其实是为了方便后续的整体放大缩小。

矩形想要出生还缺了一样东西,事件,否则画布感受不到我们想要创造矩形的想法。

// ...
const bindEvent = () => {
    canvas.value.addEventListener("mousedown", onMousedown);
    canvas.value.addEventListener("mousemove", onMousemove);
    canvas.value.addEventListener("mouseup", onMouseup);
};
const onMousedown = (e) => {};
const onMousemove = (e) => {};
const onMouseup = (e) => {};

onMounted(() => {
    initCanvas();
    bindEvent();// ++
});

一个矩形想要在画布世界上存在,需要明确”有多大“和”在哪里“,多大即它的width、height,哪里即它的x、y

当我们鼠标在画布世界按下时就决定了矩形出生的地方,所以我们需要记录一下这个位置:

let mousedownX = 0;
let mousedownY = 0;
let isMousedown = false;
const onMousedown = (e) => {
    mousedownX = e.clientX;
    mousedownY = e.clientY;
    isMousedown = true;
};

当我们的鼠标不仅按下了,还开始在画布世界中移动的那一瞬间就会创造一个矩形了,其实我们可以创造无数个矩形,它们之间是有一些共同点的,就像我们男人一样,好男人坏男人都是两只眼睛一张嘴,区别只是有的人眼睛大一点,有的人比较会花言巧语而已,所以它们是存在模子的:

// 矩形元素类
class Rectangle {
    constructor(opt) {
        this.x = opt.x || 0;
        this.y = opt.y || 0;
        this.width = opt.width || 0;
        this.height = opt.height || 0;
    }
    render() {
        ctx.beginPath();
        ctx.rect(this.x, this.y, this.width, this.height);
        ctx.stroke();
    }
}

矩形创建完成后在我们的鼠标没有松开前都是可以修改它的初始大小的:

// 当前激活的元素
let activeElement = null;
// 所有的元素
let allElements = [];
// 渲染所有元素
const renderAllElements = () => {
  allElements.forEach((element) => {
    element.render();
  });
}

const onMousemove = (e) => {
    if (!isMousedown) {
        return;
    }
    // 矩形不存在就先创建一个
    if (!activeElement) {
        activeElement = new Rectangle({
            x: mousedownX,
            y: mousedownY,
        });
        // 加入元素大家庭
        allElements.push(activeElement);
    }
    // 更新矩形的大小
    activeElement.width = e.clientX - mousedownX;
    activeElement.height = e.clientY - mousedownY;
    // 渲染所有的元素
    renderAllElements();
};

当我们的鼠标松开后,矩形就正式出生了~

const onMouseup = (e) => {
    isMousedown = false;
    activeElement = null;
    mousedownX = 0;
    mousedownY = 0;
};

2022-04-25-15-40-29.gif

what??和我们预想的不一样,首先我们的鼠标是在左上角移动,但是矩形却出生在中间位置,另外矩形大小变化的过程也显示出来了,而我们只需要看到最后一刻的大小即可。

其实我们鼠标是在另一个世界,这个世界的坐标原点在左上角,而前面我们把画布世界的原点移动到中心位置了,所以它们虽然是平行世界,但是奈何坐标系不一样,所以需要把我们鼠标的位置转换成画布的位置:

const screenToCanvas = (x, y) => {
    return {
        x: x - canvas.value.width / 2,
        y: y - canvas.value.height / 2
    }
}

然后在矩形渲染前先把坐标转一转:

class Rectangle {
    constructor(opt) {}

    render() {
        ctx.beginPath();
        // 屏幕坐标转成画布坐标
        let canvasPos = screenToCanvas(this.x, this.y);
        ctx.rect(canvasPos.x, canvasPos.y, this.width, this.height);
        ctx.stroke();
    }
}

另一个问题是因为在画布世界中,你新画一些东西时,原来画的东西是依旧存在的,所以在每一次重新画所有元素前都需要先把画布清空一下:

const clearCanvas = () => {
    let width = canvas.value.width;
    let height = canvas.value.height;
    ctx.clearRect(-width / 2, -height / 2, width, height);
};

在每次渲染矩形前先清空画布世界:

const renderAllElements = () => {
  clearCanvas();// ++
  allElements.forEach((element) => {
    element.render();
  });
}

2022-04-25-15-41-13.gif

恭喜矩形们成功出生~

成长

修理它

小时候被爸妈修理,长大后换成被世界修理,从出生起,一切就都在变化之中,时间会磨平你的棱角,也会增加你的体重,作为画布世界的操控者,当我们想要修理一下某个矩形时要怎么做呢?第一步,选中它,第二步,修理它。

1.第一步,选中它

怎么在茫茫矩形海之中选中某个矩形呢,很简单,如果鼠标击中了某个矩形的边框则代表选中了它,矩形其实就是四根线段,所以只要判断鼠标是否点击到某根线段即可,那么问题就转换成了,怎么判断一个点是否和一根线段挨的很近,因为一根线很窄所以鼠标要精准点击到是很困难的,所以我们不妨认为鼠标的点击位置距离目标10px内都认为是击中的。

首先我们可以根据点到直线的计算公式来判断一个点距离一根直线的距离:

image-20220425095139180.png

点到直线的距离公式为:

image-20220425100910804.png

// 计算点到直线的距离
const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
  // 直线公式y=kx+b不适用于直线垂直于x轴的情况,所以对于直线垂直于x轴的情况单独处理
  if (x1 === x2) {
    return Math.abs(x - x1);
  } else {
    let k, b;
    // y1 = k * x1 + b  // 0式
    // b = y1 - k * x1  // 1式

    // y2 = k * x2 + b    // 2式
    // y2 = k * x2 + y1 - k * x1  // 1式代入2式
    // y2 - y1 = k * x2 - k * x1
    // y2 - y1 = k * (x2 -  x1)
    k = (y2 - y1) / (x2 -  x1) // 3式

    b = y1 - k * x1  // 3式代入0式
    
    return Math.abs((k * x - y + b) / Math.sqrt(1 + k * k));
  }
};

但是这样还不够,因为下面这种情况显然也满足条件但是不应该认为击中了线段:

image-20220425101227980.png

因为直线是无限长的而线段不是,我们还需要再判断一下点到线段的两个端点的距离,这个点需要到两个端点的距离都满足条件才行,下图是一个点距离线段一个端点允许的最远的距离:

image-20220425112504312.png

计算两个点的距离很简单,公式如下:

image.png

这样可以得到我们最终的函数:

// 检查是否点击到了一条线段
const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
  // 点到直线的距离不满足直接返回
  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
    return false;
  }
  // 点到两个端点的距离
  let dis1 = getTowPointDistance(x, y, x1, y1);
  let dis2 = getTowPointDistance(x, y, x2, y2);
  // 线段两个端点的距离,也就是线段的长度
  let dis3 = getTowPointDistance(x1, y1, x2, y2);
  // 根据勾股定理计算斜边长度,也就是允许最远的距离
  let max = Math.sqrt(dis * dis + dis3 * dis3);
  // 点距离两个端点的距离都需要小于这个最远距离
  if (dis1 <= max && dis2 <= max) {
    return true;
  }
  return false;
};

// 计算两点之间的距离
const getTowPointDistance = (x1, y1, x2, y2) => {
  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}

然后给我们矩形的模子加一个方法:

class Rectangle {
    // 检测是否被击中
    isHit(x0, y0) {
        let { x, y, width, height } = this;
        // 矩形四条边的线段
        let segments = [
            [x, y, x + width, y],
            [x + width, y, x + width, y + height],
            [x + width, y + height, x, y + height],
            [x, y + height, x, y],
        ];
        for (let i = 0; i < segments.length; i++) {
            let segment = segments[i];
            if (
                checkIsAtSegment(x0, y0, segment[0], segment[1], segment[2], segment[3])
            ) {
                return true;
            }
        }
        return false;
    }
}

现在我们可以来修改一下鼠标按下的函数,判断我们是否击中了一个矩形:

const onMousedown = (e) => {
  // ...
  if (currentType.value === 'selection') {
    // 选择模式下进行元素激活检测
    checkIsHitElement(mousedownX, mousedownY);
  }
};

// 检测是否击中了某个元素
const checkIsHitElement = (x, y) => {
  let hitElement = null;
  // 从后往前遍历元素,即默认认为新的元素在更上层
  for (let i = allElements.length - 1; i >= 0; i--) {
    if (allElements[i].isHit(x, y)) {
      hitElement = allElements[i];
      break;
    }
  }
  if (hitElement) {
    alert("击中了矩形");
  }
};

2022-04-25-15-43-04.gif

可以看到虽然我们成功选中了矩形,但是却意外的又创造了一个新矩形,要避免这种情况我们可以新增一个变量来区分一下当前是创造矩形还是选择矩形,在正确的时候做正确的事:

<template>
  <div class="container" ref="container">
    <canvas ref="canvas"></canvas>
    <div class="toolbar">
      <el-radio-group v-model="currentType">
        <el-radio-button label="selection">选择</el-radio-button>
        <el-radio-button label="rectangle">矩形</el-radio-button>
      </el-radio-group>
    </div>
  </div>
</template>

<script setup>
// ...
// 当前操作模式
const currentType = ref('selection');
</script>

选择模式下可以选择矩形,但是不能创造新矩形,修改一下鼠标移动的方法:

const onMousemove = (e) => {
  if (!isMousedown || currentType.value === 'selection') {
    return;
  }
}

2022-04-25-15-44-43.gif

最后,选中一个矩形时为了能突出它被选中以及为了紧接着能修理它,我们给它外围画个虚线框,并再添加上一些操作手柄,先给矩形模子增加一个属性,代表它被激活了:

class Rectangle {
  constructor(opt) {
    // ...
    this.isActive = false;
  }
}

然后再给它添加一个方法,当激活时渲染激活态图形:

class Rectangle {
  render() {
    let canvasPos = screenToCanvas(this.x, this.y);
    drawRect(canvasPos.x, canvasPos.y, this.width, this.height);
    this.renderActiveState();// ++
  }

  // 当激活时渲染激活态
  renderActiveState() {
    if (!this.isActive) {
      return;
    }
    let canvasPos = screenToCanvas(this.x, this.y);
    // 为了不和矩形重叠,虚线框比矩形大一圈,增加5px的内边距
    let x = canvasPos.x - 5;
    let y = canvasPos.y - 5;
    let width = this.width + 10;
    let height = this.height + 10;
    // 主体的虚线框
    ctx.save();
    ctx.setLineDash([5]);
    drawRect(x, y, width, height);
    ctx.restore();
    // 左上角的操作手柄
    drawRect(x - 10, y - 10, 10, 10);
    // 右上角的操作手柄
    drawRect(x + width, y - 10, 10, 10);
    // 右下角的操作手柄
    drawRect(x + width, y + height, 10, 10);
    // 左下角的操作手柄
    drawRect(x - 10, y + height, 10, 10);
    // 旋转操作手柄
    drawCircle(x + width / 2, y - 10, 10);
  }
}

// 提取出公共的绘制矩形和圆的方法
// 绘制矩形
const drawRect = (x, y, width, height) => {
  ctx.beginPath();
  ctx.rect(x, y, width, height);
  ctx.stroke();
};
// 绘制圆形
const drawCircle = (x, y, r) => {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2 * Math.PI);
  ctx.stroke();
};

最后修改一下检测是否击中了元素的方法:

const checkIsHitElement = (x, y) => {
  // ...
  // 如果当前已经有激活元素则先将它取消激活
  if (activeElement) {
    activeElement.isActive = false;
  }
  // 更新当前激活元素
  activeElement = hitElement;
  if (hitElement) {
    // 如果当前击中了元素,则将它的状态修改为激活状态
    hitElement.isActive = true;
  }
  // 重新渲染所有元素
  renderAllElements();
};

2022-04-25-15-36-09.gif

可以看到激活新的矩形时并没有将之前的激活元素取消掉,原因出在我们的鼠标松开的处理函数,因为我们之前的处理是鼠标松开时就把activeElement复位成了null,修改一下:

const onMouseup = (e) => {
  isMousedown = false;
  // 选择模式下就不需要复位了
  if (currentType.value !== 'selection') {
    activeElement = null;
  }
  mousedownX = 0;
  mousedownY = 0;
};

2022-04-25-15-37-20.gif

2.第二步,修理它

终于到了万众瞩目的修理环节,不过别急,在修理之前我们还要做一件事,那就是得要知道我们鼠标具体在哪个操作手柄上,当我们激活一个矩形,它会显示激活态,然后再当我们按住了激活态的某个部位进行拖动时进行具体的修理操作,比如按住了中间的大虚线框里面则进行移动操作,按住了旋转手柄则进行矩形的旋转操作,按住了其他的四个角的操作手柄之一则进行矩形的大小调整操作。

具体的检测来说,中间的虚线框及四个角的调整手柄,都是判断一个点是否在矩形内,这个很简单:

// 判断一个坐标是否在一个矩形内
const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
};

旋转按钮是个圆,那么我们只要判断一个点到其圆心的距离,小于半径则代表在圆内,那么我们可以给矩形模子加上激活状态各个区域的检测方法:

class Rectangle {
  // 检测是否击中了激活状态的某个区域
  isHitActiveArea(x0, y0) {
    let x = this.x - 5;
    let y = this.y - 5;
    let width = this.width + 10;
    let height = this.height + 10;
    if (checkPointIsInRectangle(x0, y0, x, y, width, height)) {
      // 在中间的虚线框
      return "body";
    } else if (getTowPointDistance(x0, y0, x + width / 2, y - 10) <= 10) {
      // 在旋转手柄
      return "rotate";
    } else if (checkPointIsInRectangle(x0, y0, x + width, y + height, 10, 10)) {
      // 在右下角操作手柄
      return "bottomRight";
    }
  }
}

简单起见,四个角的操作手柄我们只演示右下角的一个,其他三个都是一样的,各位可以自行完善。

接下来又需要修改鼠标按下的方法,如果当前是选择模式,且已经有激活的矩形时,那么我们就判断是否按住了这个激活矩形的某个激活区域,如果确实按在了某个激活区域内,那么我们就设置两个标志位,记录当前是否处于矩形的调整状态中以及具体处在哪个区域,否则就进行原来的更新当前激活的矩形逻辑:

// 当前是否正在调整元素
let isAdjustmentElement = false;
// 当前按住了激活元素激活态的哪个区域
let hitActiveElementArea = "";

const onMousedown = (e) => {
  mousedownX = e.clientX;
  mousedownY = e.clientY;
  isMousedown = true;
  if (currentType.value === "selection") {
    // 选择模式下进行元素激活检测
    if (activeElement) {
      // 当前存在激活元素则判断是否按住了激活状态的某个区域
      let hitActiveArea = activeElement.isHitActiveArea(mousedownX, mousedownY);
      if (hitActiveArea) {
        // 按住了按住了激活状态的某个区域
        isAdjustmentElement = true;
        hitActiveElementArea = hitArea;
        alert(hitActiveArea);
      } else {
        // 否则进行激活元素的更新操作
        checkIsHitElement(mousedownX, mousedownY);
      }
    } else {
      checkIsHitElement(mousedownX, mousedownY);
    }
  }
};

2022-04-25-15-34-01.gif

当鼠标按住了矩形激活状态的某个区域并且鼠标开始移动时即代表进行矩形修理操作,先来看按住了虚线框时的矩形移动操作。

移动矩形

移动矩形很简单,修改它的x、y即可,首先计算鼠标当前位置和鼠标按下时的位置之差,然后把这个差值加到鼠标按下时那一瞬间的矩形的x、y上作为矩形新的坐标,那么这之前又得来修改一下咱们的矩形模子:

class Rectangle {
  constructor(opt) {
    this.x = opt.x || 0;
    this.y = opt.y || 0;
    // 记录矩形的初始位置
    this.startX = 0;// ++
    this.startY = 0;// ++
    // ...
  }
    
  // 保存矩形某一刻的状态
  save() {
    this.startX = this.x;
    this.startY = this.y;
  }

  // 移动矩形
  moveBy(ox, oy) {
    this.x = this.startX + ox;
    this.y = this.startY + oy;
  }
}

啥时候保存矩形的状态呢,当然是鼠标按住了矩形激活状态的某个区域时:

const onMousedown = (e) => {
    // ...
    if (currentType.value === "selection") {
        if (activeElement) {
            if (hitActiveArea) {
                // 按住了按住了激活状态的某个区域
                isAdjustmentElement = true;
                hitActiveElementArea = hitArea;
                activeElement.save();// ++
            }
        }
        // ...
    }
}

然后当鼠标移动时就可以进行进行的移动操作了:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      // 调整元素中
      let ox = e.clientX - mousedownX;
      let oy = e.clientY - mousedownY;
      if (hitActiveElementArea === "body") {
        // 进行移动操作
        activeElement.moveBy(ox, oy);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

不要忘记当鼠标松开时恢复标志位:

const onMouseup = (e) => {
  // ...
  if (isAdjustmentElement) {
    isAdjustmentElement = false;
    hitActiveElementArea = "";
  }
};

2022-04-25-17-11-54.gif

旋转矩形

先来修改一下矩形的模子,给它加上旋转的角度属性:

class Rectangle {
    constructor(opt) {
        // ...
        // 旋转角度
        this.rotate = opt.rotate || 0;
        // 记录矩形的初始角度
        this.startRotate = 0;
    }
}

然后修改它的渲染方法:

class Rectangle {
    render() {
        ctx.save();// ++
        let canvasPos = screenToCanvas(this.x, this.y);
        ctx.rotate(degToRad(this.rotate));// ++
        drawRect(canvasPos.x, canvasPos.y, this.width, this.height);
        this.renderActiveState();
        ctx.restore();// ++
    }
}

画布的rotate方法接收弧度为单位的值,我们保存角度值,所以需要把角度转成弧度,角度和弧度的互转公式如下:

因为360=2PI
即180=PI
所以:

1弧度=(180/π)°角度
1角度=π/180弧度
// 弧度转角度
const radToDeg = (rad) => {
  return rad * (180 / Math.PI);
};

// 角度转弧度
const degToRad = (deg) => {
  return deg * (Math.PI / 180);
};

然后和前面修改矩形的坐标套路一样,旋转时先保存初始角度,然后旋转时更新角度:

class Rectangle {
    // 保存矩形此刻的状态
    save() {
        // ...
        this.startRotate = this.rotate;
    }

    // 旋转矩形
    rotateBy(or) {
        this.rotate = this.startRotate + or;
    }
}

接下来的问题就是如何计算鼠标移动的角度了,即鼠标按下的位置到鼠标当前移动到的位置经过的角度,两个点本身并不存在啥角度,只有相对一个中心点会形成角度:

image-20220425181312806.png

这个中心点其实就是矩形的中心点,上图夹角的计算可以根据这两个点与中心点组成的线段和水平x轴形成的角度之差进行计算:

image-20220425181845910.png

这两个夹角的正切值等于它们的对边除以邻边,对边和邻边我们都可以计算出来,所以使用反正切函数即可计算出这两个角,最后再计算一下差值即可:

// 计算两个坐标以同一个中心点构成的角度
const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
  // 计算出来的是弧度值,所以需要转成角度
  return radToDeg(Math.atan2(fy - cy, fx - cx) - Math.atan2(ty - cy, tx - cx));
}

有了这个方法,接下来我们修改鼠标移动的函数:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 进行移动操作
      } else if (hitActiveElementArea === 'rotate') {
        // 进行旋转操作
        // 矩形的中心点
        let center = getRectangleCenter(activeElement);
        // 获取鼠标移动的角度
        let or = getTowPointRotate(center.x, center.y, mousedownX, mousedownY, e.clientX, e.clientY);
        activeElement.rotateBy(or);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

// 计算矩形的中心点
const getRectangleCenter = ({x, y, width, height}) => {
  return {
    x: x + width / 2,
    y: y + height / 2,
  };
}

2022-04-25-18-40-49.gif

可以看到确实旋转了,但是显然不是我们要的旋转,我们要的是矩形以自身中心进行旋转,动图里明显不是,这其实是因为canvas画布的rotate方法是以画布原点为中心进行旋转的,所以绘制矩形时需要再移动一下画布原点,移动到自身的中心,然后再进行绘制,这样旋转就相当于以自身的中心进行旋转了,不过需要注意的是,原点变了,矩形本身和激活状态的相关图形的绘制坐标均需要修改一下:

class Rectangle {
    render() {
        ctx.save();
        let canvasPos = screenToCanvas(this.x, this.y);
        // 将画布原点移动到自身的中心
        let halfWidth = this.width / 2
        let halfHeight = this.height / 2
        ctx.translate(canvasPos.x + halfWidth, canvasPos.y + halfHeight);
        // 旋转
        ctx.rotate(degToRad(this.rotate));
        // 原点变成自身中心,那么自身的坐标x,y也需要转换一下,即:canvasPos.x - (canvasPos.x + halfWidth),其实就变成了(-halfWidth, -halfHeight)
        drawRect(-halfWidth, -halfHeight, this.width, this.height);
        this.renderActiveState();
        ctx.restore();
    }

    renderActiveState() {
        if (!this.isActive) {
            return;
        }
        let halfWidth = this.width / 2     // ++
        let halfHeight = this.height / 2   // ++
        let x = -halfWidth - 5;            // this.x -> -halfWidth
        let y = -halfHeight - 5;		   // this.y -> -halfHeight
        let width = this.width + 10;
        let height = this.height + 10;
        // ...
    }
}

2022-04-25-19-08-00.gif

旋转后的问题

2022-04-25-19-10-40.gif

矩形旋转后会发现一个问题,我们明明鼠标点击在进行的边框上,但是却无法激活它,矩形想摆脱我们的控制?它想太多,原因其实很简单:

image-20220425192046034.png

虚线是矩形没有旋转时的位置,我们点击在了旋转后的边框上,但是我们的点击检测是以矩形没有旋转时进行的,因为矩形虽然旋转了,但是本质上它的x、y坐标并没有变,知道了原因解决就很简单了,我们不妨把鼠标指针的坐标以矩形中心为原点反向旋转矩形旋转的角度:

image-20220425192752165.png

好了,问题又转化成了如何求一个坐标旋转指定角度后的坐标:

image-20220425200034610.png

如上图所示,计算p1O为中心逆时针旋转黑色角度后的p2坐标,首先根据p1的坐标计算绿色角度的反正切值,然后加上已知的旋转角度得到红色的角度,无论怎么旋转,这个点距离中心的点的距离都是不变的,所以我们可以计算出p1到中心点O的距离,也就是P2到点O的距离,斜边的长度知道了, 红色的角度也知道了,那么只要根据正余弦定理即可计算出对边和邻边的长度,自然p2的坐标就知道了:

// 获取坐标经指定中心点旋转指定角度的坐标
const getRotatedPoint = (x, y, cx, cy, rotate) => {
  let deg = radToDeg(Math.atan2(y - cy, x - cx));
  let del = deg + rotate;
  let dis = getTowPointDistance(x, y, cx, cy);
  return {
    x: Math.cos(degToRad(del)) * dis + cx,
    y: Math.sin(degToRad(del)) * dis + cy,
  };
};

最后,修改一下矩形的点击检测方法:

class Rectangle {
    // 检测是否被击中
    isHit(x0, y0) {
        // 反向旋转矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }

    // 检测是否击中了激活状态的某个区域
    isHitActiveArea(x0, y0) {
        // 反向旋转矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }
}

2022-04-25-20-19-44.gif

伸缩矩形

最后一种修理矩形的方式就是伸缩矩形,即调整矩形的大小,如下图所示:

image-20220426094039264.png

虚线为伸缩前的矩形,实线为按住矩形右下角伸缩手柄拖动后的新矩形,矩形是由x、y、width、height四个属性构成的,所以计算伸缩后的矩形,其实也就是计算出新矩形的x、y、width、height,计算步骤如下(以下思路来自于https://github.com/shenhudong/snapping-demo/wiki/corner-handle。):

1.鼠标按下伸缩手柄后,计算出矩形这个角的对角点坐标diagonalPoint

image-20220426095731343.png

2.根据鼠标当前移动到的位置,再结合对角点diagonalPoint可以计算出新矩形的中心点newCenter

image-20220426100228212.png

3.新的中心点知道了,那么我们就可以把鼠标当前的坐标以新中心点反向旋转元素的角度,即可得到新矩形未旋转时的右下角坐标rp

image-20220426100551601.png

4.中心点坐标有了,右下角坐标也有了,那么计算新矩形的x、y、wdith、height都很简单了:

let width = (rp.x - newCenter.x) * 2
let height = (rp.y- newCenter.y * 2
let x = rp.x - width
let y = rp.y - height

接下来看代码实现,首先修改一下矩形的模子,新增几个属性:

class Rectangle {
    constructor(opt) {
        // ...
        // 对角点坐标
        this.diagonalPoint = {
            x: 0,
            y: 0
        }
        // 鼠标按下位置和元素的角坐标的差值,因为我们是按住了拖拽手柄,这个按下的位置是和元素的角坐标存在一定距离的,所以为了不发生突变,需要记录一下这个差值
        this.mousedownPosAndElementPosOffset = {
            x: 0,
            y: 0
        }
    }
}

然后修改一下矩形保存状态的save方法:

class Rectangle {
  // 保存矩形此刻的状态
  save(clientX, clientY, hitArea) {// 增加几个入参
    // ...
    if (hitArea === "bottomRight") {
      // 矩形的中心点坐标
      let centerPos = getRectangleCenter(this);
      // 矩形右下角的坐标
      let pos = {
        x: this.x + this.width,
        y: this.y + this.height,
      };
      // 如果元素旋转了,那么右下角坐标也要相应的旋转
      let rotatedPos = getRotatedPoint(pos.x, pos.y, centerPos.x, centerPos.y, this.rotate);
      // 计算对角点的坐标
      this.diagonalPoint.x = 2 * centerPos.x - rotatedPos.x;
      this.diagonalPoint.y = 2 * centerPos.y - rotatedPos.y;
      // 计算鼠标按下位置和元素的左上角坐标差值
      this.mousedownPosAndElementPosOffset.x = clientX - rotatedPos.x;
      this.mousedownPosAndElementPosOffset.y = clientY - rotatedPos.y;
    }
  }
}

save方法增加了几个传参,所以也要相应修改一下鼠标按下的方法,在调用save的时候传入鼠标当前的位置和按住了激活态的哪个区域。

接下来我们再给矩形的模子增加一个伸缩的方法:

class Rectangle {
  // 伸缩
  stretch(clientX, clientY, hitArea) {
    // 鼠标当前的坐标减去偏移量得到矩形这个角的坐标
    let actClientX = clientX - this.mousedownPosAndElementPosOffset.x;
    let actClientY = clientY - this.mousedownPosAndElementPosOffset.y;
    // 新的中心点
    let newCenter = {
      x: (actClientX + this.diagonalPoint.x) / 2,
      y: (actClientY + this.diagonalPoint.y) / 2,
    };
    // 获取新的角坐标经新的中心点反向旋转元素的角度后的坐标,得到矩形未旋转前的这个角坐标
    let rp = getRotatedPoint(
      actClientX,
      actClientY,
      newCenter.x,
      newCenter.y,
      -this.rotate
    );
    if (hitArea === "bottomRight") {
      // 计算新的大小
      this.width = (rp.x - newCenter.x) * 2;
      this.height = (rp.y - newCenter.y) * 2;
      // 计算新的位置
      this.x = rp.x - this.width;
      this.y = rp.y - this.height;
    }
  }
}

最后,让我们在鼠标移动函数里调用这个方法:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 进行移动操作
      } else if (hitActiveElementArea === 'rotate') {
        // 进行旋转操作
      } else if (hitActiveElementArea === 'bottomRight') {
        // 进行伸缩操作
        activeElement.stretch(e.clientX, e.clientY, hitActiveElementArea);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

2022-04-26-15-22-47.gif

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。