大家好,我在地下城七樓。在網頁互動上,Canvas的表現速度比起傳統用DOM元素操作的方式快上許多,因此常有某些互動的需求會採用Canvas來達成,這個專案會從基礎開始解說我如何實作Canvas畫板。
這個專案會盡量練習以我的角度寫出好讀的程式碼,什麼是好讀的程式碼?總該有個客觀定義,但以我目前所學來看,通常是有一定經驗的人,看夠別人寫的程式法,才能漸漸分辨好壞。不過俗話說取悅別人之前要先取悅自己,我想在這點上可以列一個最低限度的階段目標,就是先寫出讓自己好讀的程式碼。 畢竟如果自己都看得很吃力,那我想也絕對稱不上好讀。
P.S. 網路上應該可以找到不少討論這一點的文章。
Outline
- Canvas - 指定渲染環境
- 封裝畫線方法
- 鉛筆工具
- 橡皮擦工具
- Canvas轉成圖片
- 空心矩形
- 復原與重做
- 多思考一些 - 使用者體驗
指定渲染環境
Canvas 會產生一個固定範圍的畫布,但開始繪製圖型之前, 由於canvas可以有很多種渲染環境,我們必須使用getContext()方法來指定想要的渲染方式,這邊畫布使用的是2d環境。
<canvas ref="sketchpad"></canvas>
methods:{
...
setCanvas(){
let canvas = this.$refs['sketchpad']
canvas.width = window.innerWidth
canvas.height = window.innerHeight - 60
let ctx = canvas.getContext('2d')
ctx.lineCap = "round"
ctx.lineJoin = "round"
this.canvasContext = ctx
},
}
封裝畫線方法
基礎
canvas畫線之前要先呼叫beginPath方法告知開始畫線,最後用stroke()畫出邊線或是用fill()填滿,一個畫線的簡單例子:
ctx.beginPath(); // Start a new path
ctx.moveTo(30, 50); // Move the pen to (30, 50)
ctx.lineTo(150, 100); // Draw a line to (150, 100)
ctx.stroke(); // Render the path
這段程式是在告訴canvas從座標(30,50)開始畫線到(150,100),不過從beginPath開始,直到stroke()被呼叫為止,才會將線段渲染到畫板上,詳細說明可參考官方文件。
簡化
每次要畫線段都要呼叫beginPath() 跟 stroke() 實在是太多餘了,我把每次都相同的部份用methods封裝起來,中間實際會改變的部分我用callback當作參數傳入這個method,這麼一來就可以減少重複的程式碼:
methods:{
...
draw(action){
let canvasContext = this.canvasContext
canvasContext.beginPath()
canvasContext.lineWidth = this.currentSize*2
action(canvasContext)
canvasContext.stroke()
},
}
實際使用:
this.draw((ctx)=>{
ctx.strokeStyle = this.currentColor.code
ctx.moveTo(this.tempPosition.x,this.tempPosition.y)
ctx.lineTo(pos.x,pos.y)
})
如果你寫Js一段時間應該會發現常見的map或是filter也是類似的用法。
實作鉛筆工具
鉛筆工具實作上,基本上就是找出滑鼠按下的時候,滑鼠移動的軌跡,再將這些點用線段一個一個連起來,實作前有兩個問題要先想一下:
- 如何記錄移動軌跡,每個點都記錄?還是只記錄一個?要記錄什麼時間點的軌跡
- 如何得知滑鼠現在是否為按下的狀態 ?
我實作的方式是,在window新增mousemove監聽事件,事件被觸發時,記錄滑鼠移動到B點時的前一個點A點位置,然後因為在B點時的位置可以直接透過js api取得,這樣就能夠對照前後位置,以實作鉛筆功能。
window.addEventListener('mousemove',(event)=>{
...
let currentPos = this.getCanvasMousePosition(event.clientX,event.clientY)
...
...
this.setCanvasTempPosition(currentPos.x,currentPos.y)
})
取得鼠標位置的細節可參考我的Github。只看程式碼可能有點抽象,第一次移動的時候,tempPosition一定是空值,直到位置發生變化(從A到B)結束後,A點位置才會被記錄到tempPosition,完成前一軌跡記錄。
再來,如何確定滑鼠是否為按下狀態? 我在canvas元素上分別新增mousedown跟mouseup事件,並在Vue data新增一個布林值isCanvasMouseDown,以透過這兩個事件切換滑鼠狀態。
onCanvasMouseDown(){
this.isCanvasMouseDown = true
...
}
onCanvasMouseUp(){
this.isCanvasMouseDown = false
}
最後,就剩下canvas畫圖的功夫了,在畫圖之前記得先確認滑鼠狀態以及是否已有軌跡記錄之後再執行動作,否則會出錯:
In MouseMove Event
...
if(this.isCanvasMouseDown && this.tempPosition){
let pos = this.getCanvasMousePosition(event.clientX,event.clientY)
switch(this.currentTool){
case 'paint-brush' :
this.draw((ctx)=>{
ctx.strokeStyle = this.currentColor.code
ctx.moveTo(this.tempPosition.x,this.tempPosition.y)
ctx.lineTo(pos.x,pos.y)
})
break;
...
橡皮擦工具
橡皮擦工具只是把已經畫上的圖案消去,這邊我直接用前面實作的筆刷工具,搭配背景色,可以達成一樣的效果,比較特別的是,我將canvas背景色記錄在vue data裡面,這樣就可以確保橡皮擦工具的顏色跟背景色是相同的值,減少錯誤發生機率。
Canvas 轉成圖片
轉成圖片不是什麼大問題,Canvas元素本身有一個toDataUrl,可以回傳回傳含有圖像的Url,最後再產生一個a元素,將網址貼上去後觸發click事件,就可以模擬點擊,達成下載圖片的功能。
canvasToImage(){
let url = this.$refs['sketchpad'].toDataURL("image/png", 1.0).replace("image/png", "image/octet-stream");
const link = document.createElement('a')
link.innerText = 'Download'
link.href = url
link.download = 'circle.png'
link.click()
},
空心矩形
這個功能是我思考最久的一部分,canvas要單純畫上矩形的話只要呼叫rect()方法就行,方法說明如下:
ctx.rect(起始位置x, 起始位置y, 長度, 寬度);
這格應該不難理解,最難的是如何達成「滑鼠按下時可一邊調整矩形的大小跟長寬」如果像實作鉛筆功能時,在每個軌跡都畫圖型的話,就會發生災難:
看到這裡你應該可以了解原因了,因為我在每次滑鼠改變位置時都畫了一次。這邊要思考的是:
- 使用矩形工具,滑鼠按下時要做什麼事情
- 什麼時候紀錄前一個移動軌跡,什麼時候不要
我的解法是,當使用矩形工具,且偵測到mousedown事件時,在畫圖之前先用getImageData做一個簡單的暫存:
methods:{
...
setTempCanvas(){
let ctx = this.canvasContext
let canvas = ctx.canvas
let tempCanvas = ctx.getImageData(0, 0, canvas.width, canvas.height);
this.tempCanvas = tempCanvas
},
...
}
並且在每次滑鼠位置改變並且渲染矩形完成後,用putImageData() 把暫存復原,以上面災難圖為例,就是每次畫完之後,只會看到最為面那層。
復與原重做
我找到一篇描述利用window.history模擬瀏覽器上ㄧ頁下一頁功能的文章,實作步驟簡單來說就是在每次畫完圖型後將目前畫面暫存進history裡面,再去透過js操作瀏覽器上頁跟下頁來達成這個功能,蠻有趣的。
多思考一些 - 使用者體驗
有幾個細節是比較容易被忽略的,但是如果有這些小地方可以讓使用者操作或是開發上更順暢,這邊把想到的點列出:
- 畫筆畫到一半,滑過工具列時,能不能繼續畫
- 調整大小時,能不能用拖拉調整
1.畫筆畫到一半,滑過工具列時,能不能繼續畫
我原本是把Mousemove事件放在canvas上面,後來做到一半才發現當我畫到一半,滑鼠經過工具列,他不會繼續對canvas的滑鼠事件有動作,因此會中斷畫圖,後來我將Mousemove事件放在整個windows上,並判斷現在滑鼠狀態是否是按下(MouseDown),才解決這個問題。
2.調整大小時,能不能直接用拖拉調整
會想到這個是因為很多繪圖軟體都有這個功能,這邊的雷點跟第一點一樣,必須將mouseMove事件放在window上面,否則拖拉到一半滑鼠離開input元素時,就會停止調整Size數值,反而很不好用。
Vue Method:
checkSizeDrag(dragValue){
if(dragValue<0){
this.currentSize = parseInt(this.currentSize)+1
}else if(dragValue>0&&this.currentSize>=1){
this.currentSize = parseInt(this.currentSize)-1
}
},
In MouseMove Event
window.addEventListener('mousemove',(event)=>{
...
let currentPos = this.getCanvasMousePosition(event.clientX,event.clientY)
if(this.isSizing && this.tempPosition ){
let dragValue= currentPos.y- this.tempPosition.y
this.checkSizeDrag(dragValue)
}
...
}
參考文章
canvas基礎:
http://test.domojyun.net/MEMO/Canvas/
pushState 實作復原及重做:
https://ithelp.ithome.com.tw/articles/10195793