帶你從0到1實現canvas的undo和redo功能


不知不覺又到了周末,又到了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功能

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

undo棧

我在畫布中畫了3個圖形, 每一次添加瞬間我都對canvas 截圖了, 並把這個信息,保存到undoStack 了。這時候我按下 ctrl + z 回退

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

undo棧和redo棧

如果在回退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:

回退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

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

圖形相交

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

clip裁剪結果

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

anyShape

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

boundingbox

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

image-20210822113735608

任意多邊形

虛線框其實就是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 區域。我們看下圖片clip

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

// 確定裁剪范圍
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)
  })
}

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

image-20210822222513877

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

 // 局部改變圓的顏色
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】 可以獲得全部源碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM