我做了一個(gè)在線白板@虿狻!唧喉!

相信各位寫文章的朋友平時(shí)肯定都有畫圖的需求捣卤,筆者平時(shí)用的是一個(gè)在線的手繪風(fēng)格白板--excalidraw,使用體驗(yàn)上沒的說八孝,但是有一個(gè)問題董朝,不能云端保存,不過好消息它是開源的干跛,所以筆者就在想要不要基于它做一個(gè)支持云端保存的子姜,于是三下兩除二寫了幾個(gè)接口就完成了--小白板,雖然功能完成了楼入,但是壞消息是excalidraw是基于React的哥捕,而且代碼量很龐大牧抽,對于筆者這種常年寫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)行繪制晨炕。

簡單起見,我們以【一個(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)由左上角移動到中心點(diǎn)
        ctx.translate(width / 2, height / 2);
    };

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

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

矩形想要出生還缺了一樣?xùn)|西遵馆,事件,否則畫布感受不到我們想要創(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)不僅按下了,還開始在畫布世界中移動的那一瞬間就會創(chuàng)造一個(gè)矩形了喳资,其實(shí)我們可以創(chuàng)造無數(shù)個(gè)矩形觉吭,它們之間是有一些共同點(diǎn)的,就像我們男人一樣仆邓,好男人壞男人都是兩只眼睛一張嘴鲜滩,區(qū)別只是有的人眼睛大一點(diǎn),有的人比較會花言巧語而已节值,所以它們是存在模子的:

// 矩形元素類
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)是在左上角移動,但是矩形卻出生在中間位置,另外矩形大小變化的過程也顯示出來了桩皿,而我們只需要看到最后一刻的大小即可豌汇。

其實(shí)我們鼠標(biāo)是在另一個(gè)世界,這個(gè)世界的坐標(biāo)原點(diǎn)在左上角业簿,而前面我們把畫布世界的原點(diǎn)移動到中心位置了瘤礁,所以它們雖然是平行世界,但是奈何坐標(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í)間會磨平你的棱角,也會增加你的體重钝腺,作為畫布世界的操控者抛姑,當(dāng)我們想要修理一下某個(gè)矩形時(shí)要怎么做呢?第一步艳狐,選中它定硝,第二步,修理它毫目。

1.第一步蔬啡,選中它

怎么在茫茫矩形海之中選中某個(gè)矩形呢,很簡單镀虐,如果鼠標(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軸的情況,所以對于直線垂直于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)的距離很簡單,公式如下:

[圖片上傳失敗...(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 {
    // 檢測是否被擊中
    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)行元素激活檢測
    checkIsHitElement(mousedownX, mousedownY);
  }
};

// 檢測是否擊中了某個(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)移動的方法:

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

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

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è)矩形怔接,它會顯示激活態(tài),然后再當(dāng)我們按住了激活態(tài)的某個(gè)部位進(jìn)行拖動時(shí)進(jìn)行具體的修理操作稀轨,比如按住了中間的大虛線框里面則進(jìn)行移動操作扼脐,按住了旋轉(zhuǎn)手柄則進(jìn)行矩形的旋轉(zhuǎn)操作,按住了其他的四個(gè)角的操作手柄之一則進(jìn)行矩形的大小調(diào)整操作奋刽。

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

// 判斷一個(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ū)域的檢測方法:

class Rectangle {
  // 檢測是否擊中了激活狀態(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";
    }
  }
}

簡單起見罚攀,四個(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)行元素激活檢測
    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)開始移動時(shí)即代表進(jìn)行矩形修理操作,先來看按住了虛線框時(shí)的矩形移動操作摔认。

移動矩形

移動矩形很簡單逆皮,修改它的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;
  }

  // 移動矩形
  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)移動時(shí)就可以進(jìn)行進(jìn)行的移動操作了:

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)行移動操作
        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)移動的角度了,即鼠標(biāo)按下的位置到鼠標(biāo)當(dāng)前移動到的位置經(jīng)過的角度接箫,兩個(gè)點(diǎn)本身并不存在啥角度攒读,只有相對一個(gè)中心點(diǎn)會形成角度:

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

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

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

這兩個(gè)夾角的正切值等于它們的對邊除以鄰邊辛友,對邊和鄰邊我們都可以計(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)移動的函數(shù):

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 進(jìn)行移動操作
      } else if (hitActiveElementArea === 'rotate') {
        // 進(jìn)行旋轉(zhuǎn)操作
        // 矩形的中心點(diǎn)
        let center = getRectangleCenter(activeElement);
        // 獲取鼠標(biāo)移動的角度
        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)日缨,動圖里明顯不是,這其實(shí)是因?yàn)?code>canvas畫布的rotate方法是以畫布原點(diǎn)為中心進(jìn)行旋轉(zhuǎn)的掖看,所以繪制矩形時(shí)需要再移動一下畫布原點(diǎn)殿遂,移動到自身的中心诈铛,然后再進(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)移動到自身的中心
        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)后會發(fā)現(xiàn)一個(gè)問題驶乾,我們明明鼠標(biāo)點(diǎn)擊在進(jìn)行的邊框上,但是卻無法激活它循签,矩形想擺脫我們的控制级乐?它想太多,原因其實(shí)很簡單:

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

虛線是矩形沒有旋轉(zhuǎn)時(shí)的位置县匠,我們點(diǎn)擊在了旋轉(zhuǎn)后的邊框上风科,但是我們的點(diǎn)擊檢測是以矩形沒有旋轉(zhuǎn)時(shí)進(jìn)行的,因?yàn)榫匦坞m然旋轉(zhuǎn)了乞旦,但是本質(zhì)上它的x贼穆、y坐標(biāo)并沒有變,知道了原因解決就很簡單了兰粉,我們不妨把鼠標(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ì)算出對邊和鄰邊的長度备蚓,自然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)擊檢測方法:

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

    // 檢測是否擊中了激活狀態(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í)線為按住矩形右下角伸縮手柄拖動后的新矩形,矩形是由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è)角的對角點(diǎn)坐標(biāo)diagonalPoint

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

2.根據(jù)鼠標(biāo)當(dāng)前移動到的位置速蕊,再結(jié)合對角點(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都很簡單了:

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) {
        // ...
        // 對角點(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ì)算對角點(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ū)域宗雇。

接下來我們再給矩形的模子增加一個(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;
    }
  }
}

最后秩贰,讓我們在鼠標(biāo)移動函數(shù)里調(diào)用這個(gè)方法:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 進(jìn)行移動操作
      } 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)變量:scrollXscrollY椅寺,記錄畫布水平和垂直方向的滾動偏移量浑槽,以垂直方向的偏移量來介紹蒋失,當(dāng)鼠標(biāo)滾動時(shí),增加或減少scrollY桐玻,但是這個(gè)滾動值我們不直接應(yīng)用到畫布上篙挽,而是在繪制矩形的時(shí)候加上去,比如矩形用來的y100畸冲,我們向上滾動了100px嫉髓,那么實(shí)際矩形繪制的時(shí)候的y=100-100=0,這樣就達(dá)到了矩形也跟著滾動的效果邑闲。

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

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

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

然后我們再繪制矩形時(shí)加上這個(gè)滾動偏移量:

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

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

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

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

原因和矩形旋轉(zhuǎn)一樣,滾動只是最終繪制的時(shí)候加上了滾動值儡陨,但是矩形的x褪子、y仍舊沒有變化,因?yàn)槔L制時(shí)是減去了scrollY骗村,那么我們獲取到的鼠標(biāo)的clientY不妨加上scrollY嫌褪,這樣剛好抵消了,修改一下鼠標(biāo)按下和鼠標(biāo)移動的函數(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)行移動操作
            } 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),怎么辦呢琅拌,很簡單缨伊,加個(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)]

還是老掉牙的原因,無論怎么滾動縮放旋轉(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)
  // 處理滾動
  _clientX = screenClient.x;
  _clientY = screenClient.y + scrollY;
  mousedownX = _clientX;
  mousedownY = _clientY;
  // ...
}
// onMousemove方法也是同樣處理

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

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

如果我們想讓兩個(gè)矩形對齊癣猾,靠手來操作是很難的,解決方法一般有兩個(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)格也需要減去滾動值
  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();
};

代碼看著很多,但是邏輯很簡單亚隅,就是從上往下掃描和從左往右掃描硼莽,然后在繪制元素前先繪制一些網(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)對齊了行疏。

這個(gè)怎么做呢匆光,很簡單,因?yàn)榫W(wǎng)格也相當(dāng)于是從左上角開始繪制的酿联,所以我們獲取到鼠標(biāo)的clientX终息、clientY后,對網(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)擊檢測我們還是要使用未吸附的坐標(biāo):

const onMousedown = (e) => {
    // 處理縮放
    // ...
    // 處理滾動
    _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)行元素檢測的坐標(biāo)我們還是使用_clientX、_clientY惯雳,保存矩形當(dāng)前狀態(tài)的坐標(biāo)需要換成使用gridClientX石景、gridClientY
    activeElement.save(gridClientX, gridClientY, hitArea);
    // ...
}

const onMousemove = (e) => {
    // 處理縮放
    // ...
    // 處理滾動
    _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)我們滾動或縮小后刷晋,網(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ā)生滾動后鬼悠,比如向下滾動焕窝,那么上方的水平線沒了,那我們只要補(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í)刻矩形的美要怎么做呢,簡單功氨,導(dǎo)出成圖片就可以了捷凄。

導(dǎo)出圖片不能簡單的直接把畫布導(dǎo)出就行了,因?yàn)楫?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;
  }
};

代碼很多哨啃,但是邏輯很簡單,計(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)移動到畫布中心
  ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
  // 將滾動值恢復(fù)成0拳球,因?yàn)樵谛庐嫴忌喜⒉簧婕暗綕L動,所有元素距離有多遠(yuǎn)我們就會創(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才對,所以所有的元素坐標(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í)候跟我們的小矩形說再見了。

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

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

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

小結(jié)

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市蜡坊,隨后出現(xiàn)的幾起案子杠输,更是在濱河造成了極大的恐慌,老刑警劉巖秕衙,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蠢甲,死亡現(xiàn)場離奇詭異,居然都是意外死亡据忘,警方通過查閱死者的電腦和手機(jī)鹦牛,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進(jìn)店門搞糕,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人曼追,你說我怎么就攤上這事窍仰。” “怎么了礼殊?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵驹吮,是天一觀的道長。 經(jīng)常有香客問我晶伦,道長碟狞,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任坝辫,我火速辦了婚禮篷就,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘近忙。我一直安慰自己竭业,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布及舍。 她就那樣靜靜地躺著未辆,像睡著了一般。 火紅的嫁衣襯著肌膚如雪锯玛。 梳的紋絲不亂的頭發(fā)上咐柜,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天,我揣著相機(jī)與錄音攘残,去河邊找鬼拙友。 笑死,一個(gè)胖子當(dāng)著我的面吹牛歼郭,可吹牛的內(nèi)容都是我干的遗契。 我是一名探鬼主播,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼病曾,長吁一口氣:“原來是場噩夢啊……” “哼牍蜂!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起泰涂,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤鲫竞,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后逼蒙,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體从绘,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了僵井。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片赁还。...
    茶點(diǎn)故事閱讀 38,039評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖驹沿,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蹈胡,我是刑警寧澤渊季,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站罚渐,受9級特大地震影響却汉,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜荷并,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一合砂、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧源织,春花似錦翩伪、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至侠仇,卻和暖如春轻姿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背逻炊。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工互亮, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人余素。 一個(gè)月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓豹休,卻偏偏與公主長得像,于是被迫代替她去往敵國和親溺森。 傳聞我的和親對象是個(gè)殘疾皇子慕爬,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內(nèi)容