我做了一個線上白板!!!

街角小林發表於2022-04-28

相信各位寫文章的朋友平時肯定都有畫圖的需求,筆者平時用的是一個線上的手繪風格白板--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

世界太小了

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

我們新增兩個狀態變數:scrollXscrollY,記錄畫布水平和垂直方向的滾動偏移量,以垂直方向的偏移量來介紹,當滑鼠滾動時,增加或減少scrollY,但是這個滾動值我們不直接應用到畫布上,而是在繪製矩形的時候加上去,比如矩形用來的y100,我們向上滾動了100px,那麼實際矩形繪製的時候的y=100-100=0,這樣就達到了矩形也跟著滾動的效果。

// 當前滾動值
let scrollY = 0;

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

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

然後我們再繪製矩形時加上這個滾動偏移量:

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

2022-04-26-16-06-53.gif

是不是很簡單,但是問題又來了,因為滾動後會發現我們又無法啟用矩形了,而且繪製矩形也出問題了:

2022-04-26-16-11-26.gif

原因和矩形旋轉一樣,滾動只是最終繪製的時候加上了滾動值,但是矩形的x、y仍舊沒有變化,因為繪製時是減去了scrollY,那麼我們獲取到的滑鼠的clientY不妨加上scrollY,這樣剛好抵消了,修改一下滑鼠按下和滑鼠移動的函式:

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") {
                // 進行移動操作
            } 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後的值。

2022-04-26-16-18-21.gif

距離產生美

有時候矩形太小了我們想近距離看看,有時候太大了我們又想離遠一點,怎麼辦呢,很簡單,加個放大縮小的功能!

新增一個變數scale

// 當前縮放值
let scale = 1;

然後當我們繪製元素前縮放一下畫布即可:

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

新增兩個按鈕,以及兩個放大縮小的函式:

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

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

2022-04-26-16-44-38.gif

問題又又又來了朋友們,我們又無法啟用矩形以及創造新矩形又出現偏移了:

2022-04-26-16-50-02.gif

還是老掉牙的原因,無論怎麼滾動縮放旋轉,矩形的x、y本質都是不變的,沒辦法,轉換吧:

image-20220426170111431.png

同樣是修改滑鼠的clientX、clientY,先把滑鼠座標轉成畫布座標,然後縮小畫布的縮放值,最後再轉成螢幕座標即可:

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

2022-04-26-17-10-04.gif

能不能整齊一點

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

先來增加個畫網格的方法:

// 渲染網格
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();
};
// 繪製網格水平線
const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  // 不要忘了繪製網格也需要減去滾動值
  let _i = i - scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / 2, _i);
  ctx.lineTo(width / 2, _i);
  ctx.stroke();
};
// 繪製網格垂直線
const drawVerticalLine = (i) => {
  let height = canvas.value.height;
  ctx.beginPath();
  ctx.moveTo(i, -height / 2);
  ctx.lineTo(i, height / 2);
  ctx.stroke();
};

程式碼看著很多,但是邏輯很簡單,就是從上往下掃描和從左往右掃描,然後在繪製元素前先繪製一些網格:

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

進入頁面就先呼叫一下這個方法即可顯示網格:

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

image-20220426184526124.png

到這裡我們雖然繪製了網格,但是實際上沒啥用,它並不能限制我們,我們需要繪製網格的時候讓矩形貼著網格的邊,這樣繪製多個矩形的時候就能輕鬆的實現對齊了。

這個怎麼做呢,很簡單,因為網格也相當於是從左上角開始繪製的,所以我們獲取到滑鼠的clientX、clientY後,對網格的大小進行取餘,然後再減去這個餘數,即可得到最近可以吸附到的網格座標:

image-20220426185905438.png

如上圖所示,網格大小為20,滑鼠座標是(65,65)x、y都取餘計算65%20=5,然後均減去5得到吸附到的座標(60,60)

接下來修改onMousedownonMousemove函式,需要注意的是這個吸附僅用於繪製圖形,點選檢測我們還是要使用未吸附的座標:

const onMousedown = (e) => {
    // 處理縮放
    // ...
    // 處理滾動
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // 吸附到網格
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    mousedownX = gridClientX;// 改用吸附到網格的座標
    mousedownY = gridClientY;
    // ...
    // 後面進行元素檢測的座標我們還是使用_clientX、_clientY,儲存矩形當前狀態的座標需要換成使用gridClientX、gridClientY
    activeElement.save(gridClientX, gridClientY, hitArea);
    // ...
}

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

2022-04-26-19-40-51.gif

當然,上述的程式碼還是有不足的,當我們滾動或縮小後,網格就沒有鋪滿頁面了:

2022-04-26-20-09-36.gif

解決起來也不難,比如上圖,縮小以後,水平線沒有延伸到兩端,因為縮小後相當於寬度變小了,那我們只要繪製水平線時讓寬度變大即可,那麼可以除以縮放值:

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

垂直線也是一樣。

而當發生滾動後,比如向下滾動,那麼上方的水平線沒了,那我們只要補畫一下上方的水平線,水平線我們是從-height/2開始向下畫到height/2,那麼我們就從-height/2開始再向上補畫:

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

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

照個相吧

如果我們想記錄某一時刻矩形的美要怎麼做呢,簡單,匯出成圖片就可以了。

匯出圖片不能簡單的直接把畫布匯出就行了,因為當我們滾動或放大後,矩形也許都在畫布外了,或者只有一個小矩形,而我們把整個畫布都匯出了也屬實沒有必要,我們可以先計算出所有矩形的公共外包圍框,然後另外建立一個這麼大的畫布,把所有元素在這個畫布裡也繪製一份,然後再匯出這個畫布即可。

計算所有元素的外包圍框可以先計算出每一個矩形的四個角的座標,注意是要旋轉之後的,然後再迴圈所有元素進行比較,計算出minx、maxx、miny、maxy即可。

// 獲取多個元素的最外層包圍框資訊
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,
  };
}
// 獲取元素的四個角的座標,應用了旋轉之後的
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];
}
// 獲取元素旋轉後的四個角座標
const getElementRotatedCornerPoint = (element, dir) => {
  // 元素中心點
  let center = getRectangleCenter(element);
  // 元素的某個角座標
  let dirPos = getElementCornerPoint(element, dir);
  // 旋轉元素的角度
  return getRotatedPoint(
    dirPos.x,
    dirPos.y,
    center.x,
    center.y,
    element.rotate
  );
};
// 獲取元素的四個角座標
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;
  }
};

程式碼很多,但是邏輯很簡單,計算出了所有元素的外包圍框資訊,接下來就可以建立一個新畫布以及把元素繪製上去:

// 匯出為圖片
const exportImg = () => {
  // 計算所有元素的外包圍框資訊
  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");
  // 畫布原點移動到畫布中心
  ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
  // 將滾動值恢復成0,因為在新畫布上並不涉及到滾動,所有元素距離有多遠我們就會建立一個有多大的畫布
  scrollY = 0;
  // 渲染所有元素
  allElements.forEach((element) => {
    // 這裡為什麼要減去minx、miny呢,因為比如最左上角矩形的座標為(100,100),所以min、miny計算出來就是100、100,而它在我們的新畫布上繪製時應該剛好也是要繪製到左上角的,座標應該為0,0才對,所以所有的元素座標均需要減去minx、miny
    element.x -= minx;
    element.y -= miny;
    element.render();
  });
};

2022-04-27-09-58-18.gif

當然,我們替換了用來的畫布元素、繪圖上下文等,實際上應該在匯出後恢復成原來的,篇幅有限就不具體展開了。

白白

作為喜新厭舊的我們,現在是時候跟我們的小矩形說再見了。

刪除可太簡單了,直接把矩形從元素大家庭陣列裡把它去掉即可:

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

2022-04-27-10-04-06.gif

小結

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

相關文章