不知不覺又到了週末,又到了Fly寫文章的日子,今天給大家介紹下一個web中很常見的功能, 就是撤銷和復原這樣一個功能,對於任何一個畫圖軟體,或者是建模軟體。沒有撤銷和復原。這不是傻了對啊吧,所以本篇文章,可以說是基於上一篇文章Canvas 事件系統的下集,如果你沒有看過,建議看完再去看這一篇文章。讀完本篇文章你可以學習到什麼??

  1. 給canvas 繫結鍵盤事件
  2. 實現undo 和 redo
  3. 批量回退
  4. 2d包圍盒演算法
  5. 區域性渲染

繫結鍵盤事件

tabindex

很多人說繫結鍵盤事件,有什麼好講的。對雖然很簡單,但是有點小坑, 首先直接對canvas 監聽鍵盤事件,是不行的。 這裡涉及到一個小技巧, 就是給canvasdom元素 加上 tabindex 屬性 ,很多人說這是啥,我來看下官方文件。

tabindex 全域性屬性 指示其元素是否可以聚焦,以及它是否/在何處參與順序鍵盤導航(通常使用Tab鍵,因此得名)。

tabindex 可以設定 正數 和負數

  1. tabindex=負值 (通常是tabindex=“-1”),表示元素是可聚焦的,但是不能通過鍵盤導航來訪問到該元素,用JS做頁面小元件內部鍵盤導航的時候非常有用。( 可聚焦, 但是不能輸入鍵盤)
  2. tabindex=0,表示元素是可聚焦的,並且可以通過鍵盤導航來聚焦到該元素,它的相對順序是當前處於的DOM結構來決定的。
  3. tabindex=正值,表示元素是可聚焦的,並且可以通過鍵盤導航來訪問到該元素;它的相對順序按照tabindex 的數值遞增而滯後獲焦。如果多個元素擁有相同的 tabindex,它們的相對順序按照他們在當前DOM中的先後順序決定

OK,這下你應該明白了,我們要想canvas 可以聚焦, 但是直接加 tabindex = 0。 我給出以下程式碼:

 <canvas id="canvas" width="800" height="600" tabindex="0"></canvas>

 this.canvas.addEventListener(keydown,()=>{})

但是會有個問題, 你看下面圖片。

有canvas有邊框, 這個我們可以通過css 去解決, 不能讓使用者看到這個,好的互動是使用者無感知。程式碼如下:

canvas {
background: red;
outline: 0px;
}

直接canvas 的外邊框設定為0就OK了。

繫結事件

監聽完成了之後,我開始對鍵盤事件進行處理, 首先無論是Mac 還是windows 一般使用者的習慣就是 按 ctrl 或者 command, 加 z

y 之後進行回退, OK ,我們也這樣去做。

首先定義兩個變數:

export const Z = 'KeyZ'
export const Y = 'KeyY'

第二步就是寫空的undo 和redo 方法

undo() {
console.log('走了undo')
} redo() {
console.log('redo')
}

第三步開始繫結:

this.canvas.addEventListener(keydown, (e) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
if (e.code === Z) {
this.undo()
} else if (e.code === Y) {
this.redo()
}
}
})

這裡需要講解的就兩個點哈,第一個就是 阻止事件的預設行為 , 因為,我按command + y 會開啟新的標籤頁, 第二個就是相容macwindows , 一個metaKey 一個是 ctrlKey. 看下結果:

實現undo和redo功能

撤銷和復原 最主要的功能其實就是我們我們記錄每一次往畫布畫圖形的這個操作,因為我當前畫布沒有啥其他操作, 首先我們我用兩個棧資訊來,一個undo棧 一個 redo 棧。來記錄每一次畫布的資訊。 我這裡給大家畫圖演示:

我在畫布中畫了3個圖形, 每一次新增瞬間我都對canvas 截圖了, 並把這個資訊,儲存到undoStack 了。這時候我按下 ctrl + z 回退

undo棧中 只有rect 和circle,然後redo 棧 就有一個shape 了。如圖:

如果在回退undo 就只有個cicrle, redo 中有 rect 和shape, 大概就是這麼個過程。 原理搞清楚了直接看程式碼實現:

第一個先初始化屬性:

this.undoStack = []
this.redoStack = []

第二個canvas實現截圖功能主要是配合 使用 toDataUrl 這個api:

add(shape) {
shape.draw(this.ctx)
const dataUrl = this.canvas.toDataURL()
const img = new Image()
img.src = dataUrl
this.undoStack.push(img)
this.allShapes.push(shape)
}

關於這個api 的詳情 用法可以查閱 Mdn, 可以修改圖片的型別 和質量 其他沒有什麼。

第三個就是undo 和redo 方法的詳細實現

  undo() {
this.clearCanvas()
const img = this.undoStack.pop()
if (!img) {
return
}
this.ctx.drawImage(img, 0, 0)
this.redoStack.push(img)
} redo() {
this.clearCanvas()
const img = this.redoStack.pop()
if (!img) {
return
}
this.ctx.drawImage(img, 0, 0)
this.undoStack.push(img)
}

這裡 this.clearCanvas 就是清空畫布。 undo 取出 棧頂的元素, 用了canvas drawImage 的這個api , 這個是canvas 對外提供繪製圖片的能力。然後並將元素 加到 redo棧中。 這樣其實就已經實現了。 redo 的方法同理。 不清楚的同學,看我上面的畫的圖。

我們這裡直接看gif:

批量回退

這是很常見的需求,如果我們在一次操作中畫了很多 圖形,比如100個, 我如果想回到一開始的時候,我難道要一次我要回退100 次嘛?? 對於使用者來說這絕對 impossible 的 所以我們得實現一個批量回退的功能 , 其實很簡單,就是我們放入到undoStack的那張圖片 是很多圖形的就好了。給出以下實現:

batchAdd = (shapes) => {
shapes.forEach((shape) => shape.draw(this.ctx))
const dataUrl = this.canvas.toDataURL()
const img = new Image()
img.src = dataUrl
this.undoStack.push(img)
this.allShapes.push(...shapes)
}

我測試一下, 我吧矩形的新增 和任意多邊形的新增 放到一起 給出下面程式碼:

canvas.add(circle)
canvas.batchAdd([rect, shape])

我們看下gif:

pattch

其實本篇文章回退只是對圖形新增這個動作去做了回退,但是其實對於一個畫圖工具還有很多其他操作,比如修改圖形的顏色, 大小哇, 這些都是可以用來記錄的, 難道我們每次都要去重新畫整個畫布嘛, 這樣的效能 是在是太差了。所以區域性渲染, 就出來了,我們只對畫布上變化的東西去做重新繪製。 其實也就是去找出兩次的不同 去做區域性渲染。

方案

我們來思考 Canvas 區域性渲染方案時,需要看 Canvas 的 API 給我們提供了什麼樣的介面,這裡主要用到兩個方法:

通過這兩個 API 我們可以得到 Canvas 區域性重新整理的方案:

  1. 清除指定區域的顏色,並設定 clip
  2. 所有同這個區域相交的圖形重新繪製

example

為什麼所有同這個區域相交的圖形都要重新繪製, 我舉個例子:

首先看上面這張圖,如果我只改變了圓形的顏色, 那我去做裁剪的時候,首先我的裁剪路徑肯定是是這個圓, 但是同時又包含了 黑色矩形的一部分, 如果我只對圓做顏色變化的, 你會發現黑色矩形少了一部分。我給你看下 圖片:

你會發現有點奇怪對吧, 這個時候有人提出了一個問題, 為什麼整個圓呢, 3/4個圓不好嘛。OK是可以的, 你槓我,我就要在給你舉一個例子。 或者說我這裡我為什麼要給大家講一下Boundbox 的概念呢?

假設在這樣的情況下:我想做區域性渲染, 同時畫布中還有一個綠色的三角形。 那你怎麼去計算路徑呢 ??? 對吧,所以我們想著肯定得有一個框去把他們框柱, 然後框內所有的的圖形都會重畫,其他不變。是不是就好了。

boundingbox

我們剛才說的用一個框去把圖形包圍住, 其實在幾何中我們叫包圍盒 或者是boundingBox。 可以用來快速檢測兩個圖形是否相交, 但是還是不夠準確。最好還是用圖形演算法去解決。 或者遊戲中的碰撞檢測,都有這個概念。因為我這裡討論的是2d的boudingbox, 還是比較簡單的。我給你看幾張圖, 或許你就瞬間明白了。

虛線框其實就是boundingBox, 其實就是根據圖形的大小,算出一個矩形邊框。理論我們知道了,對映到程式碼層次, 我們怎麼去表達呢? 我這裡帶大家原生實現一下bound2d 類, 其實我們每個2d圖形,都可以去實現。 因為2d圖形都是由點組成的,所以只要獲得每一個圖形的離散點集合, 然後對這些點,去獲得一個2d空間的boundBox。

實現box2

box2 這個類的屬性其實就有一個min, max。 這個其實就是對應的矩形的左上角右下角 這裡是因為canvas 的座標系座標原點是左上方的, 如果座標原點在左下方。min, max 對應的就是, 左下右上。 我給出下面程式碼實現:

export class Box2 {
constructor(min, max) {
this.min = min || new Point2d(-Infinity, -Infinity)
this.max = max || new Point2d(Infinity, Infinity)
} setFromPoints(points) {
this.makeEmpty() for (let i = 0, il = points.length; i < il; i++) {
this.expandByPoint(points[i])
} return this
} containsBox(box) {
return (
this.min.x <= box.min.x &&
box.max.x <= this.max.x &&
this.min.y <= box.min.y &&
box.max.y <= this.max.y
)
} expandByPoint(point) {
this.min.min(point)
this.max.max(point)
return this
} intersectsBox(box) {
return box.max.x < this.min.x ||
box.min.x > this.max.x ||
box.max.y < this.min.y ||
box.min.y > this.max.y
? false
: true
} makeEmpty() {
this.min.x = this.min.y = +Infinity
this.max.x = this.max.y = -Infinity return this
}
}

minmax 其實對應著我之前寫的Point2d 點這個類。 由於expandPoint, 這個方法的存在。 所以相當於不斷的去比較獲取的最大的點 和最小的點, 從而獲得包圍盒。 我看下Point2d min 和 max 這個方法的實現:

min(v) {
this.x = Math.min(this.x, v.x)
this.y = Math.min(this.y, v.y)
return this
} max(v) {
this.x = Math.max(this.x, v.x)
this.y = Math.max(this.y, v.y)
return this
}

其實就是比較兩個點的x 和y 不斷地去比較。

然後我再看下, 包圍盒 是否相交 和包含這兩個方法:

我先講下 包含(containsBox)這個方法:程式碼不好理解,我還是畫一張圖就理解了:

cd 這個包圍盒 是不是在ab 包圍盒的內部 我們怎麼表示呢

Cx >= Ax && Cy >=Ay && Dx<=Bx && Dy<=By

上面的虛擬碼, 你理解了,你就理解了包圍這個方法的實現了。

然後我在看相交這個方法的實現,實現思路判斷不想交的情況就好了。

兩個包圍盒不想交的情況對應下面的這張圖:其實是分4個象限:

這是4中不想交情況, 對應的虛擬碼如下:

dx < ax || cy > by || cx > bx || ay > dy

看到這裡,我覺得你肯定有收穫,我希望你給我個和關注,我會持續輸出好文章的。

改造shape

有了boundBox, 我們給每一個圖形加一個getBounding 這個方法。 這裡就不展示了, 直接展示程式碼。

// 圓
getBounding() {
const { center, radius } = this.props
const { x, y } = center
const min = new Point2d(x - radius, y - radius)
const max = new Point2d(x + radius, y + radius)
return new Box2(min,max)
}
//矩形
getBounding() {
const { leftTop, width, height } = this.props
const min = leftTop
const { x, y } = leftTop
const max = new Point2d(x + width, y + height)
return new Box2(min, max)
}
//任意多邊形
getDispersed() {
return this.props.points
} getBounding() {
return new Box2().setFromPoints(this.getDispersed())
}

區域性渲染

一切知識都已經講結束了,我們開始進行實戰環節了。 我在底部加一個按鈕, 用於改變圓的顏色。

<button id="btn">改變圓的顏色</button>
// 改變圓的顏色
document.getElementById('btn').addEventListener(click, () => {
circle.change(
{
fillColor: 'blue',
},
canvas
)
})

同時點選的時候改變圓的顏色,我們看下 change 這個方法實現:

change(props, canvas) {
// 設定不同
canvas.shapePropsDiffMap.set(this, props)
canvas.reDraw()
}

這裡我給大家講解一下哈, 首先我們已經在畫布中已經有了這個圓,我這是對圓再一次改變,所以我將這一次的改變用一個map 記錄, 重畫這個方法 主要是區域裁剪, 但是裁剪我們要去判斷 當前圖形是不是和其他圖形有相交的,如果有相交的,我們需要擴大裁剪區域, 並且重畫多個圖形。

如果有相交的其他圖形, 這裡涉及到兩個包圍盒的合併。來確定這個裁剪區域

union( box ) {

		this.min.min( box.min );
this.max.max( box.max ); return this; }

區域合併了,我們開始進行清除包圍盒區域的圖形, 先看下程式碼實現。

reDraw() {
this.shapePropsDiffMap.forEach((props, shape) => {
shape.props = { ...shape.props, ...props }
const curBox = shape.getBounding()
const otherShapes = this.allShapes.filter(
(other) => other !== shape && other.getBounding().intersectsBox(curBox)
)
// 如果存在相交 進行包圍盒合併
if (otherShapes.length > 0) {
otherShapes.forEach((otherShape) => {
curBox.union(otherShape.getBounding())
})
}
//清除裁剪區域
this.ctx.clearRect(curBox.min.x, curBox.min.y, curBox.max.x, curBox.max.y)
})
}

裁剪的區域 就是合併的boudingBox 區域。我們看下圖片

哈哈哈成功實現, 我只改變的是圓, 接下來進行裁剪和重畫就好了程式碼如下:

// 確定裁剪範圍
this.ctx.save()
this.ctx.beginPath()
// 裁剪區域
curBox.getFourPoints().forEach((point, index) => {
const { x, y } = point
if (index === 0) {
this.ctx.moveTo(x, y)
} else {
this.ctx.lineTo(x, y)
}
})
this.ctx.clip() //重畫每一個圖形
[...otherShapes, shape].forEach((shape) => {
shape.draw(this.ctx)
}) this.ctx.closePath()
this.ctx.restore()

上面的getFourPoints, 其實是確定裁剪的路徑。 這個很重要的方法如下:

getFourPoints() {
const rightTop = new Point2d(this.max.x, this.min.y)
const leftBottom = new Point2d(this.min.x, this.max.y)
return [this.min, rightTop, this.max, leftBottom]
}

為了測試區域性渲染的優勢哈,我在畫布中畫了50個圓形,並且增加了走全部渲染的按鈕, 看看到底有沒有優勢。到底有沒有優化。

const shapes = []
for (let i = 1; i <= 50; i++) {
const circle = new Circle({
center: Point2d.random(800, 600),
radius: i + 20,
fillColor:
'rgb( ' +
((Math.random() * 255) >> 0) +
',' +
((Math.random() * 255) >> 0) +
',' +
((Math.random() * 255) >> 0) +
' )',
})
shapes.push(circle)
} reDraw2() {
this.clearCanvas()
this.allShapes.forEach((shape) => {
shape.draw(this.ctx)
})
}

然後畫布是這樣子的如圖:

分別加了時間 去測試程式碼如下:

 // 區域性改變圓的顏色
document.getElementById('btn').addEventListener(click, () => {
console.time(2)
circle.change(
{
fillColor: 'blue',
},
canvas
)
console.timeEnd(2)
}) // 全部重新整理 改變圓的顏色
document.getElementById('btn2').addEventListener(click, () => {
console.time(1)
canvas.reDraw2()
console.timeEnd(1)
})

下面我們開始測試看下gif:

大家可以發現,區域性渲染還速度還是快的。這是在50個圖形的基礎上,如果換成100個呢, 對吧,優化可能就是比較明顯的了。

總結

本篇文章寫到這裡也就結束了,如果你對文章的內容有困惑,歡迎評論區交流指正。我看到都會回覆的, 最後還是希望大家如果看完對你有幫助,希望點個贊和關注。讓更多人看到, 我是喜歡圖形的Fly,我們下期再見。

原始碼

如果對你有幫助的話,可以關注公眾號 【前端圖形】 ,回覆 【box】 可以獲得全部原始碼。