相信各位寫文章的朋友平時(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)在線demo
:https://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ì)算p1
以O
為中心逆時(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)變量:scrollX
、scrollY
椅寺,記錄畫布水平和垂直方向的滾動偏移量浑槽,以垂直方向的偏移量來介紹蒋失,當(dāng)鼠標(biāo)滾動時(shí),增加或減少scrollY
桐玻,但是這個(gè)滾動值我們不直接應(yīng)用到畫布上篙挽,而是在繪制矩形的時(shí)候加上去,比如矩形用來的y
是100
畸冲,我們向上滾動了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)
峭弟。
接下來修改onMousedown
和onMousemove
函數(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)成的元素允懂,敬請期待映之,白白~