我做了一個(gè)在線白板?。?!

相信各位寫文章的朋友平時(shí)肯定都有畫圖的需求,筆者平時(shí)用的是一個(gè)在線的手繪風(fēng)格白板--excalidraw,使用體驗(yàn)上沒的說,但是有一個(gè)問題,不能云端保存,不過好消息它是開源的,所以筆者就在想要不要基于它做一個(gè)支持云端保存的,于是三下兩除二寫了幾個(gè)接口就完成了--小白板,雖然功能完成了,但是壞消息是excalidraw是基于React的,而且代碼量很龐大,對(duì)于筆者這種常年寫Vue的人來說不是很友好,另外也無法在Vue項(xiàng)目上使用,于是閑著也是閑著,筆者就花了差不多一個(gè)月的業(yè)余時(shí)間來做了一個(gè)草率版的,框架無關(guān),先來一睹為快:

[圖片上傳失敗...(image-fd4576-1651068854739)]

也可體驗(yàn)在線demohttps://wanglin2.github.io/tiny_whiteboard_demo/。

源碼倉庫在此:https://github.com/wanglin2/tiny_whiteboard。

接下來筆者就來大致介紹一下實(shí)現(xiàn)的關(guān)鍵技術(shù)點(diǎn)。

本文的配圖均使用筆者開發(fā)的白板進(jìn)行繪制。

簡(jiǎn)單起見,我們以【一個(gè)矩形的一生】來看一下大致的整個(gè)流程實(shí)現(xiàn)。

出生

矩形即將出生的是一個(gè)叫做canvas的畫布世界,這個(gè)世界大致是這樣的:

<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");
        // 將畫布的原點(diǎn)由左上角移動(dòng)到中心點(diǎn)
        ctx.translate(width / 2, height / 2);
    };

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

為什么要將畫布世界的原點(diǎn)移動(dòng)到中心呢,其實(shí)是為了方便后續(xù)的整體放大縮小。

矩形想要出生還缺了一樣?xùn)|西,事件,否則畫布感受不到我們想要?jiǎng)?chuàng)造矩形的想法。

// ...
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();// ++
});

一個(gè)矩形想要在畫布世界上存在,需要明確”有多大“和”在哪里“,多大即它的width、height,哪里即它的x、y。

當(dāng)我們鼠標(biāo)在畫布世界按下時(shí)就決定了矩形出生的地方,所以我們需要記錄一下這個(gè)位置:

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

當(dāng)我們的鼠標(biāo)不僅按下了,還開始在畫布世界中移動(dòng)的那一瞬間就會(huì)創(chuàng)造一個(gè)矩形了,其實(shí)我們可以創(chuàng)造無數(shù)個(gè)矩形,它們之間是有一些共同點(diǎn)的,就像我們男人一樣,好男人壞男人都是兩只眼睛一張嘴,區(qū)別只是有的人眼睛大一點(diǎn),有的人比較會(huì)花言巧語而已,所以它們是存在模子的:

// 矩形元素類
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();
    }
}

矩形創(chuàng)建完成后在我們的鼠標(biāo)沒有松開前都是可以修改它的初始大小的:

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

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

當(dāng)我們的鼠標(biāo)松開后,矩形就正式出生了~

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

[圖片上傳失敗...(image-bf07ee-1651068854739)]

what??和我們預(yù)想的不一樣,首先我們的鼠標(biāo)是在左上角移動(dòng),但是矩形卻出生在中間位置,另外矩形大小變化的過程也顯示出來了,而我們只需要看到最后一刻的大小即可。

其實(shí)我們鼠標(biāo)是在另一個(gè)世界,這個(gè)世界的坐標(biāo)原點(diǎn)在左上角,而前面我們把畫布世界的原點(diǎn)移動(dòng)到中心位置了,所以它們雖然是平行世界,但是奈何坐標(biāo)系不一樣,所以需要把我們鼠標(biāo)的位置轉(zhuǎn)換成畫布的位置:

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

然后在矩形渲染前先把坐標(biāo)轉(zhuǎn)一轉(zhuǎn):

class Rectangle {
    constructor(opt) {}

    render() {
        ctx.beginPath();
        // 屏幕坐標(biāo)轉(zhuǎn)成畫布坐標(biāo)
        let canvasPos = screenToCanvas(this.x, this.y);
        ctx.rect(canvasPos.x, canvasPos.y, this.width, this.height);
        ctx.stroke();
    }
}

另一個(gè)問題是因?yàn)樵诋嫴际澜缰?,你新畫一些東西時(shí),原來畫的東西是依舊存在的,所以在每一次重新畫所有元素前都需要先把畫布清空一下:

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();
  });
}

[圖片上傳失敗...(image-818f91-1651068854739)]

恭喜矩形們成功出生~

成長

修理它

小時(shí)候被爸媽修理,長大后換成被世界修理,從出生起,一切就都在變化之中,時(shí)間會(huì)磨平你的棱角,也會(huì)增加你的體重,作為畫布世界的操控者,當(dāng)我們想要修理一下某個(gè)矩形時(shí)要怎么做呢?第一步,選中它,第二步,修理它。

1.第一步,選中它

怎么在茫茫矩形海之中選中某個(gè)矩形呢,很簡(jiǎn)單,如果鼠標(biāo)擊中了某個(gè)矩形的邊框則代表選中了它,矩形其實(shí)就是四根線段,所以只要判斷鼠標(biāo)是否點(diǎn)擊到某根線段即可,那么問題就轉(zhuǎn)換成了,怎么判斷一個(gè)點(diǎn)是否和一根線段挨的很近,因?yàn)橐桓€很窄所以鼠標(biāo)要精準(zhǔn)點(diǎn)擊到是很困難的,所以我們不妨認(rèn)為鼠標(biāo)的點(diǎn)擊位置距離目標(biāo)10px內(nèi)都認(rèn)為是擊中的。

首先我們可以根據(jù)點(diǎn)到直線的計(jì)算公式來判斷一個(gè)點(diǎn)距離一根直線的距離:

[圖片上傳失敗...(image-16bcbe-1651068854739)]

點(diǎn)到直線的距離公式為:

[圖片上傳失敗...(image-6974d7-1651068854739)]

// 計(jì)算點(diǎn)到直線的距離
const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
  // 直線公式y(tǒng)=kx+b不適用于直線垂直于x軸的情況,所以對(duì)于直線垂直于x軸的情況單獨(dú)處理
  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));
  }
};

但是這樣還不夠,因?yàn)橄旅孢@種情況顯然也滿足條件但是不應(yīng)該認(rèn)為擊中了線段:

[圖片上傳失敗...(image-8a09f5-1651068854739)]

因?yàn)橹本€是無限長的而線段不是,我們還需要再判斷一下點(diǎn)到線段的兩個(gè)端點(diǎn)的距離,這個(gè)點(diǎn)需要到兩個(gè)端點(diǎn)的距離都滿足條件才行,下圖是一個(gè)點(diǎn)距離線段一個(gè)端點(diǎn)允許的最遠(yuǎn)的距離:

[圖片上傳失敗...(image-1b9c9f-1651068854739)]

計(jì)算兩個(gè)點(diǎn)的距離很簡(jiǎn)單,公式如下:

[圖片上傳失敗...(image-22bbeb-1651068854739)]

這樣可以得到我們最終的函數(shù):

// 檢查是否點(diǎn)擊到了一條線段
const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
  // 點(diǎn)到直線的距離不滿足直接返回
  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
    return false;
  }
  // 點(diǎn)到兩個(gè)端點(diǎn)的距離
  let dis1 = getTowPointDistance(x, y, x1, y1);
  let dis2 = getTowPointDistance(x, y, x2, y2);
  // 線段兩個(gè)端點(diǎn)的距離,也就是線段的長度
  let dis3 = getTowPointDistance(x1, y1, x2, y2);
  // 根據(jù)勾股定理計(jì)算斜邊長度,也就是允許最遠(yuǎn)的距離
  let max = Math.sqrt(dis * dis + dis3 * dis3);
  // 點(diǎn)距離兩個(gè)端點(diǎn)的距離都需要小于這個(gè)最遠(yuǎn)距離
  if (dis1 <= max && dis2 <= max) {
    return true;
  }
  return false;
};

// 計(jì)算兩點(diǎn)之間的距離
const getTowPointDistance = (x1, y1, x2, y2) => {
  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}

然后給我們矩形的模子加一個(gè)方法:

class Rectangle {
    // 檢測(cè)是否被擊中
    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;
    }
}

現(xiàn)在我們可以來修改一下鼠標(biāo)按下的函數(shù),判斷我們是否擊中了一個(gè)矩形:

const onMousedown = (e) => {
  // ...
  if (currentType.value === 'selection') {
    // 選擇模式下進(jìn)行元素激活檢測(cè)
    checkIsHitElement(mousedownX, mousedownY);
  }
};

// 檢測(cè)是否擊中了某個(gè)元素
const checkIsHitElement = (x, y) => {
  let hitElement = null;
  // 從后往前遍歷元素,即默認(rèn)認(rèn)為新的元素在更上層
  for (let i = allElements.length - 1; i >= 0; i--) {
    if (allElements[i].isHit(x, y)) {
      hitElement = allElements[i];
      break;
    }
  }
  if (hitElement) {
    alert("擊中了矩形");
  }
};

[圖片上傳失敗...(image-fcd993-1651068854739)]

可以看到雖然我們成功選中了矩形,但是卻意外的又創(chuàng)造了一個(gè)新矩形,要避免這種情況我們可以新增一個(gè)變量來區(qū)分一下當(dāng)前是創(chuàng)造矩形還是選擇矩形,在正確的時(shí)候做正確的事:

<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>
// ...
// 當(dāng)前操作模式
const currentType = ref('selection');
</script>

選擇模式下可以選擇矩形,但是不能創(chuàng)造新矩形,修改一下鼠標(biāo)移動(dòng)的方法:

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

[圖片上傳失敗...(image-7f242f-1651068854739)]

最后,選中一個(gè)矩形時(shí)為了能突出它被選中以及為了緊接著能修理它,我們給它外圍畫個(gè)虛線框,并再添加上一些操作手柄,先給矩形模子增加一個(gè)屬性,代表它被激活了:

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

然后再給它添加一個(gè)方法,當(dāng)激活時(shí)渲染激活態(tài)圖形:

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

  // 當(dāng)激活時(shí)渲染激活態(tài)
  renderActiveState() {
    if (!this.isActive) {
      return;
    }
    let canvasPos = screenToCanvas(this.x, this.y);
    // 為了不和矩形重疊,虛線框比矩形大一圈,增加5px的內(nèi)邊距
    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);
    // 旋轉(zhuǎn)操作手柄
    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();
};

最后修改一下檢測(cè)是否擊中了元素的方法:

const checkIsHitElement = (x, y) => {
  // ...
  // 如果當(dāng)前已經(jīng)有激活元素則先將它取消激活
  if (activeElement) {
    activeElement.isActive = false;
  }
  // 更新當(dāng)前激活元素
  activeElement = hitElement;
  if (hitElement) {
    // 如果當(dāng)前擊中了元素,則將它的狀態(tài)修改為激活狀態(tài)
    hitElement.isActive = true;
  }
  // 重新渲染所有元素
  renderAllElements();
};

[圖片上傳失敗...(image-780171-1651068854739)]

可以看到激活新的矩形時(shí)并沒有將之前的激活元素取消掉,原因出在我們的鼠標(biāo)松開的處理函數(shù),因?yàn)槲覀冎暗奶幚硎鞘髽?biāo)松開時(shí)就把activeElement復(fù)位成了null,修改一下:

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

[圖片上傳失敗...(image-809549-1651068854739)]

2.第二步,修理它

終于到了萬眾矚目的修理環(huán)節(jié),不過別急,在修理之前我們還要做一件事,那就是得要知道我們鼠標(biāo)具體在哪個(gè)操作手柄上,當(dāng)我們激活一個(gè)矩形,它會(huì)顯示激活態(tài),然后再當(dāng)我們按住了激活態(tài)的某個(gè)部位進(jìn)行拖動(dòng)時(shí)進(jìn)行具體的修理操作,比如按住了中間的大虛線框里面則進(jìn)行移動(dòng)操作,按住了旋轉(zhuǎn)手柄則進(jìn)行矩形的旋轉(zhuǎn)操作,按住了其他的四個(gè)角的操作手柄之一則進(jìn)行矩形的大小調(diào)整操作。

具體的檢測(cè)來說,中間的虛線框及四個(gè)角的調(diào)整手柄,都是判斷一個(gè)點(diǎn)是否在矩形內(nèi),這個(gè)很簡(jiǎn)單:

// 判斷一個(gè)坐標(biāo)是否在一個(gè)矩形內(nèi)
const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
};

旋轉(zhuǎn)按鈕是個(gè)圓,那么我們只要判斷一個(gè)點(diǎn)到其圓心的距離,小于半徑則代表在圓內(nèi),那么我們可以給矩形模子加上激活狀態(tài)各個(gè)區(qū)域的檢測(cè)方法:

class Rectangle {
  // 檢測(cè)是否擊中了激活狀態(tài)的某個(gè)區(qū)域
  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) {
      // 在旋轉(zhuǎn)手柄
      return "rotate";
    } else if (checkPointIsInRectangle(x0, y0, x + width, y + height, 10, 10)) {
      // 在右下角操作手柄
      return "bottomRight";
    }
  }
}

簡(jiǎn)單起見,四個(gè)角的操作手柄我們只演示右下角的一個(gè),其他三個(gè)都是一樣的,各位可以自行完善。

接下來又需要修改鼠標(biāo)按下的方法,如果當(dāng)前是選擇模式,且已經(jīng)有激活的矩形時(shí),那么我們就判斷是否按住了這個(gè)激活矩形的某個(gè)激活區(qū)域,如果確實(shí)按在了某個(gè)激活區(qū)域內(nèi),那么我們就設(shè)置兩個(gè)標(biāo)志位,記錄當(dāng)前是否處于矩形的調(diào)整狀態(tài)中以及具體處在哪個(gè)區(qū)域,否則就進(jìn)行原來的更新當(dāng)前激活的矩形邏輯:

// 當(dāng)前是否正在調(diào)整元素
let isAdjustmentElement = false;
// 當(dāng)前按住了激活元素激活態(tài)的哪個(gè)區(qū)域
let hitActiveElementArea = "";

const onMousedown = (e) => {
  mousedownX = e.clientX;
  mousedownY = e.clientY;
  isMousedown = true;
  if (currentType.value === "selection") {
    // 選擇模式下進(jìn)行元素激活檢測(cè)
    if (activeElement) {
      // 當(dāng)前存在激活元素則判斷是否按住了激活狀態(tài)的某個(gè)區(qū)域
      let hitActiveArea = activeElement.isHitActiveArea(mousedownX, mousedownY);
      if (hitActiveArea) {
        // 按住了按住了激活狀態(tài)的某個(gè)區(qū)域
        isAdjustmentElement = true;
        hitActiveElementArea = hitArea;
        alert(hitActiveArea);
      } else {
        // 否則進(jìn)行激活元素的更新操作
        checkIsHitElement(mousedownX, mousedownY);
      }
    } else {
      checkIsHitElement(mousedownX, mousedownY);
    }
  }
};

[圖片上傳失敗...(image-66a51-1651068854739)]

當(dāng)鼠標(biāo)按住了矩形激活狀態(tài)的某個(gè)區(qū)域并且鼠標(biāo)開始移動(dòng)時(shí)即代表進(jìn)行矩形修理操作,先來看按住了虛線框時(shí)的矩形移動(dòng)操作。

移動(dòng)矩形

移動(dòng)矩形很簡(jiǎn)單,修改它的x、y即可,首先計(jì)算鼠標(biāo)當(dāng)前位置和鼠標(biāo)按下時(shí)的位置之差,然后把這個(gè)差值加到鼠標(biāo)按下時(shí)那一瞬間的矩形的x、y上作為矩形新的坐標(biāo),那么這之前又得來修改一下咱們的矩形模子:

class Rectangle {
  constructor(opt) {
    this.x = opt.x || 0;
    this.y = opt.y || 0;
    // 記錄矩形的初始位置
    this.startX = 0;// ++
    this.startY = 0;// ++
    // ...
  }
    
  // 保存矩形某一刻的狀態(tài)
  save() {
    this.startX = this.x;
    this.startY = this.y;
  }

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

啥時(shí)候保存矩形的狀態(tài)呢,當(dāng)然是鼠標(biāo)按住了矩形激活狀態(tài)的某個(gè)區(qū)域時(shí):

const onMousedown = (e) => {
    // ...
    if (currentType.value === "selection") {
        if (activeElement) {
            if (hitActiveArea) {
                // 按住了按住了激活狀態(tài)的某個(gè)區(qū)域
                isAdjustmentElement = true;
                hitActiveElementArea = hitArea;
                activeElement.save();// ++
            }
        }
        // ...
    }
}

然后當(dāng)鼠標(biāo)移動(dòng)時(shí)就可以進(jìn)行進(jìn)行的移動(dòng)操作了:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      // 調(diào)整元素中
      let ox = e.clientX - mousedownX;
      let oy = e.clientY - mousedownY;
      if (hitActiveElementArea === "body") {
        // 進(jìn)行移動(dòng)操作
        activeElement.moveBy(ox, oy);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

不要忘記當(dāng)鼠標(biāo)松開時(shí)恢復(fù)標(biāo)志位:

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

[圖片上傳失敗...(image-66f3f3-1651068854739)]

旋轉(zhuǎn)矩形

先來修改一下矩形的模子,給它加上旋轉(zhuǎn)的角度屬性:

class Rectangle {
    constructor(opt) {
        // ...
        // 旋轉(zhuǎn)角度
        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方法接收弧度為單位的值,我們保存角度值,所以需要把角度轉(zhuǎn)成弧度,角度和弧度的互轉(zhuǎn)公式如下:

因?yàn)?60度=2PI
即180度=PI
所以:

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

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

然后和前面修改矩形的坐標(biāo)套路一樣,旋轉(zhuǎn)時(shí)先保存初始角度,然后旋轉(zhuǎn)時(shí)更新角度:

class Rectangle {
    // 保存矩形此刻的狀態(tài)
    save() {
        // ...
        this.startRotate = this.rotate;
    }

    // 旋轉(zhuǎn)矩形
    rotateBy(or) {
        this.rotate = this.startRotate + or;
    }
}

接下來的問題就是如何計(jì)算鼠標(biāo)移動(dòng)的角度了,即鼠標(biāo)按下的位置到鼠標(biāo)當(dāng)前移動(dòng)到的位置經(jīng)過的角度,兩個(gè)點(diǎn)本身并不存在啥角度,只有相對(duì)一個(gè)中心點(diǎn)會(huì)形成角度:

[圖片上傳失敗...(image-eb2e0b-1651068854739)]

這個(gè)中心點(diǎn)其實(shí)就是矩形的中心點(diǎn),上圖夾角的計(jì)算可以根據(jù)這兩個(gè)點(diǎn)與中心點(diǎn)組成的線段和水平x軸形成的角度之差進(jìn)行計(jì)算:

[圖片上傳失敗...(image-c8e0ce-1651068854739)]

這兩個(gè)夾角的正切值等于它們的對(duì)邊除以鄰邊,對(duì)邊和鄰邊我們都可以計(jì)算出來,所以使用反正切函數(shù)即可計(jì)算出這兩個(gè)角,最后再計(jì)算一下差值即可:

// 計(jì)算兩個(gè)坐標(biāo)以同一個(gè)中心點(diǎn)構(gòu)成的角度
const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
  // 計(jì)算出來的是弧度值,所以需要轉(zhuǎn)成角度
  return radToDeg(Math.atan2(fy - cy, fx - cx) - Math.atan2(ty - cy, tx - cx));
}

有了這個(gè)方法,接下來我們修改鼠標(biāo)移動(dòng)的函數(shù):

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 進(jìn)行移動(dòng)操作
      } else if (hitActiveElementArea === 'rotate') {
        // 進(jìn)行旋轉(zhuǎn)操作
        // 矩形的中心點(diǎn)
        let center = getRectangleCenter(activeElement);
        // 獲取鼠標(biāo)移動(dòng)的角度
        let or = getTowPointRotate(center.x, center.y, mousedownX, mousedownY, e.clientX, e.clientY);
        activeElement.rotateBy(or);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

// 計(jì)算矩形的中心點(diǎn)
const getRectangleCenter = ({x, y, width, height}) => {
  return {
    x: x + width / 2,
    y: y + height / 2,
  };
}

[圖片上傳失敗...(image-1cc1f6-1651068854739)]

可以看到確實(shí)旋轉(zhuǎn)了,但是顯然不是我們要的旋轉(zhuǎn),我們要的是矩形以自身中心進(jìn)行旋轉(zhuǎn),動(dòng)圖里明顯不是,這其實(shí)是因?yàn)?code>canvas畫布的rotate方法是以畫布原點(diǎn)為中心進(jìn)行旋轉(zhuǎn)的,所以繪制矩形時(shí)需要再移動(dòng)一下畫布原點(diǎn),移動(dòng)到自身的中心,然后再進(jìn)行繪制,這樣旋轉(zhuǎn)就相當(dāng)于以自身的中心進(jìn)行旋轉(zhuǎn)了,不過需要注意的是,原點(diǎn)變了,矩形本身和激活狀態(tài)的相關(guān)圖形的繪制坐標(biāo)均需要修改一下:

class Rectangle {
    render() {
        ctx.save();
        let canvasPos = screenToCanvas(this.x, this.y);
        // 將畫布原點(diǎn)移動(dòng)到自身的中心
        let halfWidth = this.width / 2
        let halfHeight = this.height / 2
        ctx.translate(canvasPos.x + halfWidth, canvasPos.y + halfHeight);
        // 旋轉(zhuǎn)
        ctx.rotate(degToRad(this.rotate));
        // 原點(diǎn)變成自身中心,那么自身的坐標(biāo)x,y也需要轉(zhuǎn)換一下,即:canvasPos.x - (canvasPos.x + halfWidth),其實(shí)就變成了(-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;
        // ...
    }
}

[圖片上傳失敗...(image-e22b86-1651068854739)]

旋轉(zhuǎn)后的問題

[圖片上傳失敗...(image-9be49e-1651068854739)]

矩形旋轉(zhuǎn)后會(huì)發(fā)現(xiàn)一個(gè)問題,我們明明鼠標(biāo)點(diǎn)擊在進(jìn)行的邊框上,但是卻無法激活它,矩形想擺脫我們的控制?它想太多,原因其實(shí)很簡(jiǎn)單:

[圖片上傳失敗...(image-c7952b-1651068854739)]

虛線是矩形沒有旋轉(zhuǎn)時(shí)的位置,我們點(diǎn)擊在了旋轉(zhuǎn)后的邊框上,但是我們的點(diǎn)擊檢測(cè)是以矩形沒有旋轉(zhuǎn)時(shí)進(jìn)行的,因?yàn)榫匦坞m然旋轉(zhuǎn)了,但是本質(zhì)上它的x、y坐標(biāo)并沒有變,知道了原因解決就很簡(jiǎn)單了,我們不妨把鼠標(biāo)指針的坐標(biāo)以矩形中心為原點(diǎn)反向旋轉(zhuǎn)矩形旋轉(zhuǎn)的角度:

[圖片上傳失敗...(image-83690f-1651068854739)]

好了,問題又轉(zhuǎn)化成了如何求一個(gè)坐標(biāo)旋轉(zhuǎn)指定角度后的坐標(biāo):

[圖片上傳失敗...(image-f785db-1651068854739)]

如上圖所示,計(jì)算p1O為中心逆時(shí)針旋轉(zhuǎn)黑色角度后的p2坐標(biāo),首先根據(jù)p1的坐標(biāo)計(jì)算綠色角度的反正切值,然后加上已知的旋轉(zhuǎn)角度得到紅色的角度,無論怎么旋轉(zhuǎn),這個(gè)點(diǎn)距離中心的點(diǎn)的距離都是不變的,所以我們可以計(jì)算出p1到中心點(diǎn)O的距離,也就是P2到點(diǎn)O的距離,斜邊的長度知道了, 紅色的角度也知道了,那么只要根據(jù)正余弦定理即可計(jì)算出對(duì)邊和鄰邊的長度,自然p2的坐標(biāo)就知道了:

// 獲取坐標(biāo)經(jīng)指定中心點(diǎn)旋轉(zhuǎn)指定角度的坐標(biāo)
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,
  };
};

最后,修改一下矩形的點(diǎn)擊檢測(cè)方法:

class Rectangle {
    // 檢測(cè)是否被擊中
    isHit(x0, y0) {
        // 反向旋轉(zhuǎn)矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }

    // 檢測(cè)是否擊中了激活狀態(tài)的某個(gè)區(qū)域
    isHitActiveArea(x0, y0) {
        // 反向旋轉(zhuǎn)矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }
}

[圖片上傳失敗...(image-1c33b8-1651068854739)]

伸縮矩形

最后一種修理矩形的方式就是伸縮矩形,即調(diào)整矩形的大小,如下圖所示:

[圖片上傳失敗...(image-e673f0-1651068854739)]

虛線為伸縮前的矩形,實(shí)線為按住矩形右下角伸縮手柄拖動(dòng)后的新矩形,矩形是由x、y、width、height四個(gè)屬性構(gòu)成的,所以計(jì)算伸縮后的矩形,其實(shí)也就是計(jì)算出新矩形的x、y、width、height,計(jì)算步驟如下(以下思路來自于https://github.com/shenhudong/snapping-demo/wiki/corner-handle。):

1.鼠標(biāo)按下伸縮手柄后,計(jì)算出矩形這個(gè)角的對(duì)角點(diǎn)坐標(biāo)diagonalPoint

[圖片上傳失敗...(image-7d5a0f-1651068854739)]

2.根據(jù)鼠標(biāo)當(dāng)前移動(dòng)到的位置,再結(jié)合對(duì)角點(diǎn)diagonalPoint可以計(jì)算出新矩形的中心點(diǎn)newCenter

[圖片上傳失敗...(image-b47f48-1651068854739)]

3.新的中心點(diǎn)知道了,那么我們就可以把鼠標(biāo)當(dāng)前的坐標(biāo)以新中心點(diǎn)反向旋轉(zhuǎn)元素的角度,即可得到新矩形未旋轉(zhuǎn)時(shí)的右下角坐標(biāo)rp

[圖片上傳失敗...(image-573df1-1651068854740)]

4.中心點(diǎn)坐標(biāo)有了,右下角坐標(biāo)也有了,那么計(jì)算新矩形的x、y、wdith、height都很簡(jiǎn)單了:

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

接下來看代碼實(shí)現(xiàn),首先修改一下矩形的模子,新增幾個(gè)屬性:

class Rectangle {
    constructor(opt) {
        // ...
        // 對(duì)角點(diǎn)坐標(biāo)
        this.diagonalPoint = {
            x: 0,
            y: 0
        }
        // 鼠標(biāo)按下位置和元素的角坐標(biāo)的差值,因?yàn)槲覀兪前醋×送献直?,這個(gè)按下的位置是和元素的角坐標(biāo)存在一定距離的,所以為了不發(fā)生突變,需要記錄一下這個(gè)差值
        this.mousedownPosAndElementPosOffset = {
            x: 0,
            y: 0
        }
    }
}

然后修改一下矩形保存狀態(tài)的save方法:

class Rectangle {
  // 保存矩形此刻的狀態(tài)
  save(clientX, clientY, hitArea) {// 增加幾個(gè)入?yún)?    // ...
    if (hitArea === "bottomRight") {
      // 矩形的中心點(diǎn)坐標(biāo)
      let centerPos = getRectangleCenter(this);
      // 矩形右下角的坐標(biāo)
      let pos = {
        x: this.x + this.width,
        y: this.y + this.height,
      };
      // 如果元素旋轉(zhuǎn)了,那么右下角坐標(biāo)也要相應(yīng)的旋轉(zhuǎn)
      let rotatedPos = getRotatedPoint(pos.x, pos.y, centerPos.x, centerPos.y, this.rotate);
      // 計(jì)算對(duì)角點(diǎn)的坐標(biāo)
      this.diagonalPoint.x = 2 * centerPos.x - rotatedPos.x;
      this.diagonalPoint.y = 2 * centerPos.y - rotatedPos.y;
      // 計(jì)算鼠標(biāo)按下位置和元素的左上角坐標(biāo)差值
      this.mousedownPosAndElementPosOffset.x = clientX - rotatedPos.x;
      this.mousedownPosAndElementPosOffset.y = clientY - rotatedPos.y;
    }
  }
}

save方法增加了幾個(gè)傳參,所以也要相應(yīng)修改一下鼠標(biāo)按下的方法,在調(diào)用save的時(shí)候傳入鼠標(biāo)當(dāng)前的位置和按住了激活態(tài)的哪個(gè)區(qū)域。

接下來我們?cè)俳o矩形的模子增加一個(gè)伸縮的方法:

class Rectangle {
  // 伸縮
  stretch(clientX, clientY, hitArea) {
    // 鼠標(biāo)當(dāng)前的坐標(biāo)減去偏移量得到矩形這個(gè)角的坐標(biāo)
    let actClientX = clientX - this.mousedownPosAndElementPosOffset.x;
    let actClientY = clientY - this.mousedownPosAndElementPosOffset.y;
    // 新的中心點(diǎn)
    let newCenter = {
      x: (actClientX + this.diagonalPoint.x) / 2,
      y: (actClientY + this.diagonalPoint.y) / 2,
    };
    // 獲取新的角坐標(biāo)經(jīng)新的中心點(diǎn)反向旋轉(zhuǎn)元素的角度后的坐標(biāo),得到矩形未旋轉(zhuǎn)前的這個(gè)角坐標(biāo)
    let rp = getRotatedPoint(
      actClientX,
      actClientY,
      newCenter.x,
      newCenter.y,
      -this.rotate
    );
    if (hitArea === "bottomRight") {
      // 計(jì)算新的大小
      this.width = (rp.x - newCenter.x) * 2;
      this.height = (rp.y - newCenter.y) * 2;
      // 計(jì)算新的位置
      this.x = rp.x - this.width;
      this.y = rp.y - this.height;
    }
  }
}

最后,讓我們?cè)谑髽?biāo)移動(dòng)函數(shù)里調(diào)用這個(gè)方法:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 進(jìn)行移動(dòng)操作
      } else if (hitActiveElementArea === 'rotate') {
        // 進(jìn)行旋轉(zhuǎn)操作
      } else if (hitActiveElementArea === 'bottomRight') {
        // 進(jìn)行伸縮操作
        activeElement.stretch(e.clientX, e.clientY, hitActiveElementArea);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

[圖片上傳失敗...(image-54af6b-1651068854740)]

世界太小了

有一天我們的小矩形說,世界這么大,它想去看看,確實(shí),屏幕就這么大,矩形肯定早就待膩了,作為萬能的畫布操控者,讓我們來滿足它的要求。

我們新增兩個(gè)狀態(tài)變量:scrollX、scrollY,記錄畫布水平和垂直方向的滾動(dòng)偏移量,以垂直方向的偏移量來介紹,當(dāng)鼠標(biāo)滾動(dòng)時(shí),增加或減少scrollY,但是這個(gè)滾動(dòng)值我們不直接應(yīng)用到畫布上,而是在繪制矩形的時(shí)候加上去,比如矩形用來的y100,我們向上滾動(dòng)了100px,那么實(shí)際矩形繪制的時(shí)候的y=100-100=0,這樣就達(dá)到了矩形也跟著滾動(dòng)的效果。

// 當(dāng)前滾動(dòng)值
let scrollY = 0;

// 監(jiān)聽事件
const bindEvent = () => {
  // ...
  canvas.value.addEventListener("mousewheel", onMousewheel);
};

// 鼠標(biāo)移動(dòng)事件
const onMousewheel = (e) => {
  if (e.wheelDelta < 0) {
    // 向下滾動(dòng)
    scrollY += 50;
  } else {
    // 向上滾動(dòng)
    scrollY -= 50;
  }
  // 重新渲染所有元素
  renderAllElements();
};

然后我們?cè)倮L制矩形時(shí)加上這個(gè)滾動(dòng)偏移量:

class Rectangle {
    render() {
        ctx.save();
        let _x = this.x;
        let _y = this.y - scrollY;
        let canvasPos = screenToCanvas(_x, _y);
        // ...
    }
}

[圖片上傳失敗...(image-3653a4-1651068854740)]

是不是很簡(jiǎn)單,但是問題又來了,因?yàn)闈L動(dòng)后會(huì)發(fā)現(xiàn)我們又無法激活矩形了,而且繪制矩形也出問題了:

[圖片上傳失敗...(image-c7318b-1651068854740)]

原因和矩形旋轉(zhuǎn)一樣,滾動(dòng)只是最終繪制的時(shí)候加上了滾動(dòng)值,但是矩形的x、y仍舊沒有變化,因?yàn)槔L制時(shí)是減去了scrollY,那么我們獲取到的鼠標(biāo)的clientY不妨加上scrollY,這樣剛好抵消了,修改一下鼠標(biāo)按下和鼠標(biāo)移動(dòng)的函數(shù):

const onMousedown = (e) => {
    let _clientX = e.clientX;
    let _clientY = e.clientY + scrollY;
    mousedownX = _clientX;
    mousedownY = _clientY;
    // ...
}

const onMousemove = (e) => {
    if (!isMousedown) {
        return;
    }
    let _clientX = e.clientX;
    let _clientY = e.clientY + scrollY;
    if (currentType.value === "selection") {
        if (isAdjustmentElement) {
            let ox = _clientX - mousedownX;
            let oy = _clientY - mousedownY;
            if (hitActiveElementArea === "body") {
                // 進(jìn)行移動(dòng)操作
            } else if (hitActiveElementArea === "rotate") {
                // ...
                let or = getTowPointRotate(
                  center.x,
                  center.y,
                  mousedownX,
                  mousedownY,
                  _clientX,
                  _clientY
                );
                // ...
            }
        }
    }
    // ...
    // 更新矩形的大小
    activeElement.width = _clientX - mousedownX;
    activeElement.height = _clientY - mousedownY;
    // ...
}

反正把之前所有使用e.clientY的地方都修改成加上scrollY后的值。

[圖片上傳失敗...(image-e653c4-1651068854740)]

距離產(chǎn)生美

有時(shí)候矩形太小了我們想近距離看看,有時(shí)候太大了我們又想離遠(yuǎn)一點(diǎn),怎么辦呢,很簡(jiǎn)單,加個(gè)放大縮小的功能!

新增一個(gè)變量scale

// 當(dāng)前縮放值
let scale = 1;

然后當(dāng)我們繪制元素前縮放一下畫布即可:

// 渲染所有元素
const renderAllElements = () => {
  clearCanvas();
  ctx.save();// ++
  // 整體縮放
  ctx.scale(scale, scale);// ++
  allElements.forEach((element) => {
    element.render();
  });
  ctx.restore();// ++
};

添加兩個(gè)按鈕,以及兩個(gè)放大縮小的函數(shù):

// 放大
const zoomIn = () => {
  scale += 0.1;
  renderAllElements();
};

// 縮小
const zoomOut = () => {
  scale -= 0.1;
  renderAllElements();
};

[圖片上傳失敗...(image-64267a-1651068854740)]

問題又又又來了朋友們,我們又無法激活矩形以及創(chuàng)造新矩形又出現(xiàn)偏移了:

[圖片上傳失敗...(image-7e5ea7-1651068854740)]

還是老掉牙的原因,無論怎么滾動(dòng)縮放旋轉(zhuǎn),矩形的x、y本質(zhì)都是不變的,沒辦法,轉(zhuǎn)換吧:

[圖片上傳失敗...(image-e502ed-1651068854740)]

同樣是修改鼠標(biāo)的clientX、clientY,先把鼠標(biāo)坐標(biāo)轉(zhuǎn)成畫布坐標(biāo),然后縮小畫布的縮放值,最后再轉(zhuǎn)成屏幕坐標(biāo)即可:

const onMousedown = (e) => {
  // 處理縮放
  let canvasClient = screenToCanvas(e.clientX, e.clientY);// 屏幕坐標(biāo)轉(zhuǎn)成畫布坐標(biāo)
  let _clientX = canvasClient.x / scale;// 縮小畫布的縮放值
  let _clientY = canvasClient.y / scale;
  let screenClient = canvasToScreen(_clientX, _clientY)// 畫布坐標(biāo)轉(zhuǎn)回屏幕坐標(biāo)
  // 處理滾動(dòng)
  _clientX = screenClient.x;
  _clientY = screenClient.y + scrollY;
  mousedownX = _clientX;
  mousedownY = _clientY;
  // ...
}
// onMousemove方法也是同樣處理

[圖片上傳失敗...(image-583bdb-1651068854740)]

能不能整齊一點(diǎn)

如果我們想讓兩個(gè)矩形對(duì)齊,靠手來操作是很難的,解決方法一般有兩個(gè),一是增加吸附的功能,二是通過網(wǎng)格,吸附功能是需要一定計(jì)算量的,本來咱們就不富裕的性能就更加雪上加霜了,所以咱們選擇使用網(wǎng)格。

先來增加個(gè)畫網(wǎng)格的方法:

// 渲染網(wǎng)格
const renderGrid = () => {
  ctx.save();
  ctx.strokeStyle = "#dfe0e1";
  let width = canvas.value.width;
  let height = canvas.value.height;
  // 水平線,從上往下畫
  for (let i = -height / 2; i < height / 2; i += 20) {
    drawHorizontalLine(i);
  }
  // 垂直線,從左往右畫
  for (let i = -width / 2; i < width / 2; i += 20) {
    drawVerticalLine(i);
  }
  ctx.restore();
};
// 繪制網(wǎng)格水平線
const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  // 不要忘了繪制網(wǎng)格也需要減去滾動(dòng)值
  let _i = i - scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / 2, _i);
  ctx.lineTo(width / 2, _i);
  ctx.stroke();
};
// 繪制網(wǎng)格垂直線
const drawVerticalLine = (i) => {
  let height = canvas.value.height;
  ctx.beginPath();
  ctx.moveTo(i, -height / 2);
  ctx.lineTo(i, height / 2);
  ctx.stroke();
};

代碼看著很多,但是邏輯很簡(jiǎn)單,就是從上往下掃描和從左往右掃描,然后在繪制元素前先繪制一些網(wǎng)格:

const renderAllElements = () => {
  clearCanvas();
  ctx.save();
  ctx.scale(scale, scale);
  renderGrid();// ++
  allElements.forEach((element) => {
    element.render();
  });
  ctx.restore();
};

進(jìn)入頁面就先調(diào)用一下這個(gè)方法即可顯示網(wǎng)格:

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

[圖片上傳失敗...(image-f0410e-1651068854740)]

到這里我們雖然繪制了網(wǎng)格,但是實(shí)際上沒啥用,它并不能限制我們,我們需要繪制網(wǎng)格的時(shí)候讓矩形貼著網(wǎng)格的邊,這樣繪制多個(gè)矩形的時(shí)候就能輕松的實(shí)現(xiàn)對(duì)齊了。

這個(gè)怎么做呢,很簡(jiǎn)單,因?yàn)榫W(wǎng)格也相當(dāng)于是從左上角開始繪制的,所以我們獲取到鼠標(biāo)的clientX、clientY后,對(duì)網(wǎng)格的大小進(jìn)行取余,然后再減去這個(gè)余數(shù),即可得到最近可以吸附到的網(wǎng)格坐標(biāo):

[圖片上傳失敗...(image-5d3a9d-1651068854740)]

如上圖所示,網(wǎng)格大小為20,鼠標(biāo)坐標(biāo)是(65,65),x、y都取余計(jì)算65%20=5,然后均減去5得到吸附到的坐標(biāo)(60,60)。

接下來修改onMousedownonMousemove函數(shù),需要注意的是這個(gè)吸附僅用于繪制圖形,點(diǎn)擊檢測(cè)我們還是要使用未吸附的坐標(biāo):

const onMousedown = (e) => {
    // 處理縮放
    // ...
    // 處理滾動(dòng)
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // 吸附到網(wǎng)格
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    mousedownX = gridClientX;// 改用吸附到網(wǎng)格的坐標(biāo)
    mousedownY = gridClientY;
    // ...
    // 后面進(jìn)行元素檢測(cè)的坐標(biāo)我們還是使用_clientX、_clientY,保存矩形當(dāng)前狀態(tài)的坐標(biāo)需要換成使用gridClientX、gridClientY
    activeElement.save(gridClientX, gridClientY, hitArea);
    // ...
}

const onMousemove = (e) => {
    // 處理縮放
    // ...
    // 處理滾動(dòng)
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // 吸附到網(wǎng)格
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    // 后面所有的坐標(biāo)都由_clientX、_clientY改成使用gridClientX、gridClientY
}

[圖片上傳失敗...(image-8ae55a-1651068854740)]

當(dāng)然,上述的代碼還是有不足的,當(dāng)我們滾動(dòng)或縮小后,網(wǎng)格就沒有鋪滿頁面了:

[圖片上傳失敗...(image-ec0ede-1651068854740)]

解決起來也不難,比如上圖,縮小以后,水平線沒有延伸到兩端,因?yàn)榭s小后相當(dāng)于寬度變小了,那我們只要繪制水平線時(shí)讓寬度變大即可,那么可以除以縮放值:

const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  let _i = i + scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / scale / 2, _i);// ++
  ctx.lineTo(width / scale / 2, _i);// ++
  ctx.stroke();
};

垂直線也是一樣。

而當(dāng)發(fā)生滾動(dòng)后,比如向下滾動(dòng),那么上方的水平線沒了,那我們只要補(bǔ)畫一下上方的水平線,水平線我們是從-height/2開始向下畫到height/2,那么我們就從-height/2開始再向上補(bǔ)畫:

const renderGrid = () => {
    // ...
    // 水平線
    for (let i = -height / 2; i < height / 2; i += 20) {
        drawHorizontalLine(i);
    }
    // 向下滾時(shí)繪制上方超出部分的水平線
    for (
        let i = -height / 2 - 20;
        i > -height / 2 + scrollY;
        i -= 20
    ) {
        drawHorizontalLine(i);
    }
    // ...
}

限于篇幅就不再展開,各位可以閱讀源碼或自行完善。

照個(gè)相吧

如果我們想記錄某一時(shí)刻矩形的美要怎么做呢,簡(jiǎn)單,導(dǎo)出成圖片就可以了。

導(dǎo)出圖片不能簡(jiǎn)單的直接把畫布導(dǎo)出就行了,因?yàn)楫?dāng)我們滾動(dòng)或放大后,矩形也許都在畫布外了,或者只有一個(gè)小矩形,而我們把整個(gè)畫布都導(dǎo)出了也屬實(shí)沒有必要,我們可以先計(jì)算出所有矩形的公共外包圍框,然后另外創(chuàng)建一個(gè)這么大的畫布,把所有元素在這個(gè)畫布里也繪制一份,然后再導(dǎo)出這個(gè)畫布即可。

計(jì)算所有元素的外包圍框可以先計(jì)算出每一個(gè)矩形的四個(gè)角的坐標(biāo),注意是要旋轉(zhuǎn)之后的,然后再循環(huán)所有元素進(jìn)行比較,計(jì)算出minx、maxx、miny、maxy即可。

// 獲取多個(gè)元素的最外層包圍框信息
const getMultiElementRectInfo = (elementList = []) => {
  if (elementList.length <= 0) {
    return {
      minx: 0,
      maxx: 0,
      miny: 0,
      maxy: 0,
    };
  }
  let minx = Infinity;
  let maxx = -Infinity;
  let miny = Infinity;
  let maxy = -Infinity;
  elementList.forEach((element) => {
    let pointList = getElementCorners(element);
    pointList.forEach(({ x, y }) => {
      if (x < minx) {
        minx = x;
      }
      if (x > maxx) {
        maxx = x;
      }
      if (y < miny) {
        miny = y;
      }
      if (y > maxy) {
        maxy = y;
      }
    });
  });
  return {
    minx,
    maxx,
    miny,
    maxy,
  };
}
// 獲取元素的四個(gè)角的坐標(biāo),應(yīng)用了旋轉(zhuǎn)之后的
const getElementCorners = (element) => {
  // 左上角
  let topLeft = getElementRotatedCornerPoint(element, "topLeft")
  // 右上角
  let topRight = getElementRotatedCornerPoint(element, "topRight");
  // 左下角
  let bottomLeft = getElementRotatedCornerPoint(element, "bottomLeft");
  // 右下角
  let bottomRight = getElementRotatedCornerPoint(element, "bottomRight");
  return [topLeft, topRight, bottomLeft, bottomRight];
}
// 獲取元素旋轉(zhuǎn)后的四個(gè)角坐標(biāo)
const getElementRotatedCornerPoint = (element, dir) => {
  // 元素中心點(diǎn)
  let center = getRectangleCenter(element);
  // 元素的某個(gè)角坐標(biāo)
  let dirPos = getElementCornerPoint(element, dir);
  // 旋轉(zhuǎn)元素的角度
  return getRotatedPoint(
    dirPos.x,
    dirPos.y,
    center.x,
    center.y,
    element.rotate
  );
};
// 獲取元素的四個(gè)角坐標(biāo)
const getElementCornerPoint = (element, dir) => {
  let { x, y, width, height } = element;
  switch (dir) {
    case "topLeft":
      return {
        x,
        y,
      };
    case "topRight":
      return {
        x: x + width,
        y,
      };
    case "bottomRight":
      return {
        x: x + width,
        y: y + height,
      };
    case "bottomLeft":
      return {
        x,
        y: y + height,
      };
    default:
      break;
  }
};

代碼很多,但是邏輯很簡(jiǎn)單,計(jì)算出了所有元素的外包圍框信息,接下來就可以創(chuàng)建一個(gè)新畫布以及把元素繪制上去:

// 導(dǎo)出為圖片
const exportImg = () => {
  // 計(jì)算所有元素的外包圍框信息
  let { minx, maxx, miny, maxy } = getMultiElementRectInfo(allElements);
  let width = maxx - minx;
  let height = maxy - miny;
  // 替換之前的canvas
  canvas.value = document.createElement("canvas");
  canvas.value.style.cssText = `
    position: absolute;
    left: 0;
    top: 0;
    border: 1px solid red;
    background-color: #fff;
  `;
  canvas.value.width = width;
  canvas.value.height = height;
  document.body.appendChild(canvas.value);
  // 替換之前的繪圖上下文
  ctx = canvas.value.getContext("2d");
  // 畫布原點(diǎn)移動(dòng)到畫布中心
  ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
  // 將滾動(dòng)值恢復(fù)成0,因?yàn)樵谛庐嫴忌喜⒉簧婕暗綕L動(dòng),所有元素距離有多遠(yuǎn)我們就會(huì)創(chuàng)建一個(gè)有多大的畫布
  scrollY = 0;
  // 渲染所有元素
  allElements.forEach((element) => {
    // 這里為什么要減去minx、miny呢,因?yàn)楸热缱钭笊辖蔷匦蔚淖鴺?biāo)為(100,100),所以min、miny計(jì)算出來就是100、100,而它在我們的新畫布上繪制時(shí)應(yīng)該剛好也是要繪制到左上角的,坐標(biāo)應(yīng)該為0,0才對(duì),所以所有的元素坐標(biāo)均需要減去minx、miny
    element.x -= minx;
    element.y -= miny;
    element.render();
  });
};

[圖片上傳失敗...(image-37001c-1651068854740)]

當(dāng)然,我們替換了用來的畫布元素、繪圖上下文等,實(shí)際上應(yīng)該在導(dǎo)出后恢復(fù)成原來的,篇幅有限就不具體展開了。

白白

作為喜新厭舊的我們,現(xiàn)在是時(shí)候跟我們的小矩形說再見了。

刪除可太簡(jiǎn)單了,直接把矩形從元素大家庭數(shù)組里把它去掉即可:

const deleteActiveElement = () => {
  if (!activeElement) {
    return;
  }
  let index = allElements.findIndex((element) => {
    return element === activeElement;
  });
  allElements.splice(index, 1);
  renderAllElements();
};

[圖片上傳失敗...(image-8771a2-1651068854740)]

小結(jié)

以上就是白板的核心邏輯,是不是很簡(jiǎn)單,如果有下一篇的話筆者會(huì)繼續(xù)為大家介紹一下箭頭的繪制、自由書寫、文字的繪制,以及如何按比例縮放文字圖片等這些需要固定長寬比例的圖形、如何縮放自由書寫折線這些由多個(gè)點(diǎn)構(gòu)成的元素,敬請(qǐng)期待,白白~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容