哈哈哈俺又來啦,這次帶來的是canvas實現一些畫布功能的文章,希望大家喜歡!

前言
因為也是大三了,最近俺也在找實習,之前有一個自己的小項目:
https://github.com/zhcxk1998/School-Partners
面試官說可以往深層次思考一下,或許加一些新的功能來增加項目的難度,他提了幾個建議,其中一個就是 試卷在線批閱,老師可以在上面對作業進行批注,圈圈點點等 俺當天晚上就開始研究這個東東哈哈哈,終于被我研究出來啦!
采用的是 canvas
繪制畫筆,由css3的 transform
屬性來進行平移與縮放,之后再詳細介紹介紹
(希望大家可以留下寶貴的贊與star嘻嘻)
效果預覽

動圖是放cdn的,如果訪問不了,可以登錄在線嘗試嘗試: test.algbb.cn/#/admin/con…
公式推導 如果不想看公式如何推導,可以直接跳過看后面的具體實現~ 1. 坐標轉換公式 轉換公式介紹
其實一開始也是想在網上找一下有沒有相關的資料,但是可惜找不到,所以就自己慢慢的推出來了。我就舉一下橫坐標的例子吧!
通用公式
這個公式是表示,通過公式來將鼠標按下的坐標轉換為畫布中的相對坐標,這一點尤為重要
(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
參數解釋
transformOrigin: transform變化的基點(通過這個屬性來控制元素以哪里進行變化)
downX: 鼠標按下的坐標(注意,用的時候需要減去容器左偏移距離,因為我們要的是相對于容器的坐標)
scale: 縮放倍數,默認為1
translateX: 平移的距離
推導過程
這個公式的話,其實就比較通用,可以用在別的利用到 transform
屬性的場景,至于怎么推導的話,我是用的笨辦法
具體的測試代碼,放在文末,需要自取~
1. 先做出兩個相同的元素,然后標記上坐標,并且設置容器屬性 overflow:hidden
來隱藏溢出內容

ok,現在就有兩個一樣的矩陣啦,我們為他標記上一些紅點,然后我們對左邊的進行css3的樣式變化 transform
矩形的寬高是 360px * 360px
的,我們定義一下他的變化屬性,變化基點選擇正中心,放大3倍
// css
transform-origin: 180px 180px;
transform: scale(3, 3);
得到如下結果

ok,我們現在對比一下上面的結果,就會發現,放大3倍的時候,恰好是中間黑色方塊占據了全部寬度。接下來我們就可以對這些點與原先沒有進行變化(右邊)的矩形進行對比就可以得到他們坐標的關系啦
2. 開始對兩個坐標進行對比,然后推出公式
現在舉一個簡單的例子吧,例如我們算一下左上角的坐標(現在已經標記為黃色了)

其實我們其實就可以直接心算出來坐標的關系啦
( 這里左邊計算坐標的值是我們鼠標按下的坐標 )
( 這里左邊計算坐標的值是我們鼠標按下的坐標 )
( 這里左邊計算坐標的值是我們鼠標按下的坐標 )
- 因為寬高是
360px
,所以分成3等份,每份寬度是 120px
- 因為變化之后容器的寬高是不變的,變化的只有矩形本身
- 我們可以得出左邊的黃色標記坐標是
x:120 y:0
,右邊的黃色標記為 x:160 y:120
(這個其實肉眼看應該就能看出來了,實在不行可以用紙筆算一算)
這個坐標可能有點特殊,我們再換幾個來計算計算(根據特殊推一般)

藍色標記:左邊: x:120 y:120
,右邊: x: 160 y:160
綠色標記:左邊: x: 240 y:240
,右邊: x: 200: y:200
好了,我們差不多已經可以拿到坐標之間的關系了,我們可以列一個表

還覺得不放心?我們可以換一下,縮放倍數與容器寬高等進行計算

不知道大家有沒有感覺呢,然后我們就可以慢慢根據坐標推出通用的公式啦
(transformOrigin - downX) / scale * (scale-1) + down - translateX = point
當然,我們或許還有這個 translateX
沒有嘗試,這個就比較簡單一點了,腦內模擬一下,就知道我們可以減去位移的距離就ok啦。我們測試一下
我們先修改一下樣式,新增一下位移的距離
transform-origin: 180px 180px;
transform: scale(3, 3) translate(-40px,-40px);

還是我們上面的狀態,ok,我們現在藍色跟綠色的標記還是一一對應的,那我們看看現在的坐標情況
- 藍色:左邊:
x:0 y:0
,右邊: x:160 y:160
- 綠色:左邊:
x:120 y:120
,右邊: x:200 y:200
我們分別運用公式算一下出來的坐標是怎么樣的 (以下為經過坐標換算)
藍色:左邊: x:120 y:120
,右邊: x:160 y:160
綠色:左邊: x:160 y:160
,右邊: x:200 y:200
不難發現,我們其實就相差了與位移距離 translateX/translateY
的差值,所以,我們只需要減去位移的距離就可以完美的進行坐標轉換啦
測試公式
根據上面的公式,我們可以簡單測試一下!這個公式到底能不能生效!!!
我們直接沿用上面的demo,測試一下如果元素進行了變化,我們鼠標點下的地方生成一個標記,位置是否顯示正確。看起來很ok啊(手動滑稽)
const wrap = document.getElementById('wrap')
wrap.onmousedown = function (e) {
const downX = e.pageX - wrap.offsetLeft
const downY = e.pageY - wrap.offsetTop
const scale = 3
const translateX = -40
const translateY = -40
const transformOriginX = 180
const transformOriginY = 180
const dot = document.getElementById('dot')
dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'
dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'
}

可能有人會問,為什么要減去這個 offsetLeft
跟 offsetTop
呢,因為我們上面反復強調,我們計算的是鼠標點擊的坐標,而這個坐標還是相對于我們展示容器的坐標,所以我們要減去容器本身的偏移量才行。
組件設計
既然demo啥的都已經測試了ok了,我們接下來就逐一分析一下這個組件應該咋設計好呢(目前仍為低配版,之后再進行優化完善)
1. 基本的畫布構成

我們先簡單分析一下這個構成吧,其實主要就是一個畫布的容器,右邊一個工具欄,僅此而已

大體就這樣子啦!
<div className="mark-paper__wrap" ref={wrapRef}>
<canvas
ref={canvasRef}
className="mark-paper__canvas">
<p>很可惜,這個東東與您的電腦不搭!</p>
</canvas>
<div className="mark-paper__sider" />
</div>
我們唯一需要的一點就是,容器需要設置屬性 overflow: hidden
用來隱藏內部canvas畫布溢出的內容,也就是說,我們要控制我們可視的區域。同時我們需要動態獲取容器寬高來為canvas設置尺寸
2. 初始化canvas畫布與填充圖片
我們可以弄個方法來初始化并且填充畫布,以下截取主要部分,其實就是為canvas畫布設置尺寸與填充我們的圖片
const fillImage = async () => {
// 此處省略...
const img: HTMLImageElement = new Image()
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
canvas.width = img.width
canvas.height = img.height
context.drawImage(img, 0, 0)
// 設置變化基點,為畫布容器中央
canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
// 清除上一次變化的效果
canvas.style.transform = ''
}
}
3. 監聽canvas畫布的各種鼠標事件
這個控制移動的話,我們首先可以弄一個方法來監聽畫布鼠標的各種事件,可以區分不同的模式來進行不同的事件處理
const handleCanvas = () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context || !wrap) return
// 清除上一次設置的監聽,以防獲取參數錯誤
wrap.onmousedown = null
wrap.onmousedown = function (event: MouseEvent) {
const downX: number = event.pageX
const downY: number = event.pageY
// 區分我們現在選擇的鼠標模式:移動、畫筆、橡皮擦
switch (mouseMode) {
case MOVE_MODE:
handleMoveMode(downX, downY)
break
case LINE_MODE:
handleLineMode(downX, downY)
break
case ERASER_MODE:
handleEraserMode(downX, downY)
break
default:
break
}
}
4. 實現畫布移動
這個就比較好辦啦,我們只需要利用鼠標按下的坐標,和我們拖動的距離就可以實現畫布的移動啦,因為涉及到每次移動都需要計算最新的位移距離,我們可以定義幾個變量來進行計算。
這里監聽的是容器的鼠標事件,而不是canvas畫布的事件,因為這樣子我們可以再移動超過邊界的時候也可以進行移動操作

簡單的總結一下:
- 傳入鼠標按下的坐標
- 計算當前位移距離,并更新css變化效果
- 鼠標抬起時更新最新的位移狀態
// 定義一些變量,來保存當前/最新的移動狀態
// 當前位移的距離
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
// 上一次位移結束的位移距離
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)
// 移動時候的監聽函數
const handleMoveMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const { current: fillStartPointX } = fillStartPointXRef
const { current: fillStartPointY } = fillStartPointYRef
if (!canvas || !wrap || mouseMode !== 0) return
// 為容器添加移動事件,可以在空白處移動圖片
wrap.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX
const moveY: number = event.pageY
// 更新現在的位移距離,值為:上一次位移結束的坐標+移動的距離
translatePointXRef.current = fillStartPointX + (moveX - downX)
translatePointYRef.current = fillStartPointY + (moveY - downY)
// 更新畫布的css變化
canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
}
wrap.onmouseup = (event: MouseEvent) => {
const upX: number = event.pageX
const upY: number = event.pageY
// 取消事件監聽
wrap.onmousemove = null
wrap.onmouseup = null;
// 鼠標抬起時候,更新“上一次唯一結束的坐標”
fillStartPointXRef.current = fillStartPointX + (upX - downX)
fillStartPointYRef.current = fillStartPointY + (upY - downY)
}
}
5. 實現畫布縮放
畫布縮放我主要通過右側的滑動條以及鼠標滾輪來實現,首先我們再監聽畫布鼠標事件的函數中加一下監聽滾輪的事件
總結一下:
// 監聽鼠標滾輪,更新畫布縮放倍數
const handleCanvas = () => {
const { current: wrap } = wrapRef
// 省略一萬字...
wrap.onwheel = null
wrap.onwheel = (e: MouseWheelEvent) => {
const { deltaY } = e
// 這里要注意一下,我是0.1來遞增遞減,但是因為JS使用IEEE 754,來計算,所以精度有問題,我們自己處理一下
const newScale: number = deltaY > 0
? (canvasScale * 10 - 0.1 * 10) / 10
: (canvasScale * 10 + 0.1 * 10) / 10
if (newScale < 0.1 || newScale > 2) return
setCanvasScale(newScale)
}
}
// 監聽滑動條來控制縮放
<Slider
min={0.1}
max={2.01}
step={0.1}
value={canvasScale}
tipFormatter={(value) => `${(value).toFixed(2)}x`}
onChange={handleScaleChange} />
const handleScaleChange = (value: number) => {
setCanvasScale(value)
}
接著我們使用hooks的副作用函數,依賴于畫布縮放倍數來進行樣式的更新
//監聽縮放畫布
useEffect(() => {
const { current: canvas } = canvasRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])
6. 實現畫筆繪制
這個就需要用到我們之前推導出來的公式啦!因為呢,仔細想一下,如果我們縮放位移之后,我們鼠標按下的位置,他的坐標可能就相對于畫布來說會有變化, 所以我們需要轉換一下才能進行鼠標按下的位置與畫布的位置一一對應的效果
稍微總結一下:
- 傳入鼠標按下的坐標
- 通過公式轉換,開始在對應坐標下繪制
- 鼠標抬起時,取消事件監聽
// 利用公式轉換一下坐標
const generateLinePoint = (x: number, y: number) => {
const { current: wrap } = wrapRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
const wrapWidth: number = wrap?.offsetWidth || 0
const wrapHeight: number = wrap?.offsetHeight || 0
// 縮放位移坐標變化規律
// (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY
return {
pointX,
pointY
}
}
// 監聽鼠標畫筆事件
const handleLineMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
// 減去畫布偏移的距離(以畫布為基準進行計算坐標)
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.globalCompositeOperation = "source-over"
context.beginPath()
// 設置畫筆起點
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
// 開始繪制畫筆線條~
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
7. 橡皮擦的實現
橡皮擦目前還有點問題,現在的話是通過將 canvas
畫布的背景圖片 + globalCompositeOperation
這個屬性來模擬橡皮擦的實現,不過,這時候圖片生成出來之后,橡皮擦的痕跡會變成白色,而不是透明
此步驟與畫筆實現差不多,只有一點點小變動
設置屬性 context.globalCompositeOperation = "destination-out"
// 目前橡皮擦還有點問題,前端顯示正常,保存圖片下來,擦除的痕跡會變成白色
const handleEraserMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.globalCompositeOperation = "destination-out"
context.lineWidth = lineWidth
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
8. 撤銷與恢復的功能實現
這個的話,我們首先需要了解常見的撤銷與恢復的功能的邏輯 分幾種情況吧
- 若當前狀態處于第一個位置,則不允許撤銷
- 若當前狀態處于最后一個位置,則不允許恢復
- 如果當前撤銷了,然而更新了狀態,則取當前狀態為最新的狀態(也就是說不允許恢復了,這個剛更新的狀態就是最新的)
畫布狀態的更新
所以我們需要設置一些變量來存,狀態列表,與當前畫筆的狀態下標
// 定義參數存東東
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
我們還需要在初始化canvas的時候,我們就添加入當前的狀態存入列表中,作為最先開始的空畫布狀態
const fillImage = async () => {
// 省略一萬字...
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
canvasHistroyListRef.current = []
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(1)
}
}
然后我們就實現一下,畫筆更新時候,我們也需要將當前的狀態添加入 畫筆狀態列表 ,并且更新當前狀態對應的下標,還需要處理一下一些細節
總結一下:
- 鼠標抬起時,獲取當前canvas畫布狀態
- 添加進狀態列表中,并且更新狀態下標
- 如果當前處于撤銷狀態,若使用畫筆更新狀態,則將當前的最為最新的狀態,原先位置之后的狀態全部清空
const handleLineMode = (downX: number, downY: number) => {
// 省略一萬字...
canvas.onmouseup = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
// 如果此時處于撤銷狀態,此時再使用畫筆,則將之后的狀態清空,以剛畫的作為最新的畫布狀態
if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
}
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(canvasCurrentHistory + 1)
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
畫布狀態的撤銷與恢復
ok,其實現在關于畫布狀態的更新,我們已經完成了。接下來我們需要處理一下狀態的撤銷與恢復的功能啦
我們先定義一下這個工具欄吧

然后我們設置對應的事件,分別是撤銷,恢復,與清空,其實都很容易看懂,最多就是處理一下邊界情況。
const handleRollBack = () => {
const isFirstHistory: boolean = canvasCurrentHistory === 1
if (isFirstHistory) return
setCanvasCurrentHistory(canvasCurrentHistory - 1)
}
const handleRollForward = () => {
const { current: canvasHistroyList } = canvasHistroyListRef
const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
if (isLastHistory) return
setCanvasCurrentHistory(canvasCurrentHistory + 1)
}
const handleClearCanvasClick = () => {
const { current: canvas } = canvasRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
// 清空畫布歷史
canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
setCanvasCurrentHistory(1)
message.success('畫布清除成功!')
}
事件設置好之后,我們就可以開始監聽一下這個 canvasCurrentHistory
當前狀態下標,使用副作用函數進行處理
useEffect(() => {
const { current: canvas } = canvasRef
const { current: canvasHistroyList } = canvasHistroyListRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
}, [canvasCurrentHistory])
為canvas畫布填充圖像信息!
這樣就大功告成啦!!!
9. 實現鼠標圖標的變化
我們簡單的處理一下,畫筆模式則是畫筆的圖標,橡皮擦模式下鼠標是橡皮擦,移動模式下就是普通的移動圖標
切換模式時候,設置一下不同的圖標
const handleMouseModeChange = (event: RadioChangeEvent) => {
const { target: { value } } = event
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
setmouseMode(value)
if (!canvas || !wrap) return
switch (value) {
case MOVE_MODE:
canvas.style.cursor = 'move'
wrap.style.cursor = 'move'
break
case LINE_MODE:
canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
case ERASER_MODE:
message.warning('橡皮擦功能尚未完善,保存圖片會出現錯誤')
canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
default:
canvas.style.cursor = 'default'
wrap.style.cursor = 'default'
break
}
}
10. 切換圖片
現在的話只是一個demo狀態,通過點擊選擇框,切換不同的圖片

// 重置變換參數,重新繪制圖片
useEffect(() => {
setIsLoading(true)
translatePointXRef.current = 0
translatePointYRef.current = 0
fillStartPointXRef.current = 0
fillStartPointYRef.current = 0
setCanvasScale(1)
fillImage()
}, [fillImageSrc])
const handlePaperChange = (value: string) => {
const fillImageList = {
'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
}
setFillImageSrc(fillImageList[value])
}
注意事項
注意容器的偏移量
我們需要注意一下,因為公式中的 downX
是相對容器的坐標,也就是說,我們需要減去容器的偏移量,這種情況會出現在使用了 margin
等參數,或者說上方或者左側有別的元素的情況
我們輸出一下我們紅色的元素的 offsetLeft
等屬性,會發現他是已經本身就有50的偏移量了,我們計算鼠標點擊的坐標的時候就要減去這一部分的偏移量

window.onload = function () {
const test = document.getElementById('test')
console.log(`offsetLeft: ${test.offsetLeft}, offsetHeight: ${test.offsetTop}`)
}
html,
body {
margin: 0;
padding: 0;
}
#test {
width: 50px;
height: 50px;
margin-left: 50px;
background: red;
}
<div class="container">
<div id="test"></div>
</div>
注意父組件使用relative相對布局的情況
假如我們現在有一種這種的布局,打印紅色元素的偏移量,看起來都挺正常的

但是如果我們目標元素的父元素(也就是黃色部分)設置 relative
相對布局
.wrap {
position: relative;
width: 400px;
height: 300px;
background: yellow;
}
<div class="container">
<div class="sider"></div>
<div class="wrap">
<div id="test"></div>
</div>
</div>
這時候我們打印出來的偏移量會是多少呢

兩次答案不一樣啊,因為我們的偏移量是根據相對位置來計算的,如果父容器使用相對布局,則會影響我們子元素的偏移量
組件代碼(低配版)
import React, { FC, ComponentType, useEffect, useRef, RefObject, useState, MutableRefObject } from 'react'
import { CustomBreadcrumb } from '@/admin/components'
import { RouteComponentProps } from 'react-router-dom';
import { FormComponentProps } from 'antd/lib/form';
import {
Slider, Radio, Button, Tooltip, Icon, Select, Spin, message, Popconfirm
} from 'antd';
import './index.scss'
import { RadioChangeEvent } from 'antd/lib/radio';
import { getURLBase64 } from '@/admin/utils/getURLBase64'
const { Option, OptGroup } = Select;
type MarkPaperProps = RouteComponentProps & FormComponentProps
const MarkPaper: FC<MarkPaperProps> = (props: MarkPaperProps) => {
const MOVE_MODE: number = 0
const LINE_MODE: number = 1
const ERASER_MODE: number = 2
const canvasRef: RefObject<HTMLCanvasElement> = useRef(null)
const containerRef: RefObject<HTMLDivElement> = useRef(null)
const wrapRef: RefObject<HTMLDivElement> = useRef(null)
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [lineColor, setLineColor] = useState<string>('#fa4b2a')
const [fillImageSrc, setFillImageSrc] = useState<string>('')
const [mouseMode, setmouseMode] = useState<number>(MOVE_MODE)
const [lineWidth, setLineWidth] = useState<number>(5)
const [canvasScale, setCanvasScale] = useState<number>(1)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
useEffect(() => {
setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg')
}, [])
// 重置變換參數,重新繪制圖片
useEffect(() => {
setIsLoading(true)
translatePointXRef.current = 0
translatePointYRef.current = 0
fillStartPointXRef.current = 0
fillStartPointYRef.current = 0
setCanvasScale(1)
fillImage()
}, [fillImageSrc])
// 畫布參數變動時,重新監聽canvas
useEffect(() => {
handleCanvas()
}, [mouseMode, canvasScale, canvasCurrentHistory])
// 監聽畫筆顏色變化
useEffect(() => {
const { current: canvas } = canvasRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context) return
context.strokeStyle = lineColor
context.lineWidth = lineWidth
context.lineJoin = 'round'
context.lineCap = 'round'
}, [lineWidth, lineColor])
//監聽縮放畫布
useEffect(() => {
const { current: canvas } = canvasRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])
useEffect(() => {
const { current: canvas } = canvasRef
const { current: canvasHistroyList } = canvasHistroyListRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
}, [canvasCurrentHistory])
const fillImage = async () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
const img: HTMLImageElement = new Image()
if (!canvas || !wrap || !context) return
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
// 取中間渲染圖片
// const centerX: number = canvas && canvas.width / 2 - img.width / 2 || 0
// const centerY: number = canvas && canvas.height / 2 - img.height / 2 || 0
canvas.width = img.width
canvas.height = img.height
// 背景設置為圖片,橡皮擦的效果才能出來
canvas.style.background = `url(${img.src})`
context.drawImage(img, 0, 0)
context.strokeStyle = lineColor
context.lineWidth = lineWidth
context.lineJoin = 'round'
context.lineCap = 'round'
// 設置變化基點,為畫布容器中央
canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
// 清除上一次變化的效果
canvas.style.transform = ''
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
canvasHistroyListRef.current = []
canvasHistroyListRef.current.push(imageData)
// canvasCurrentHistoryRef.current = 1
setCanvasCurrentHistory(1)
setTimeout(() => { setIsLoading(false) }, 500)
}
}
const generateLinePoint = (x: number, y: number) => {
const { current: wrap } = wrapRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
const wrapWidth: number = wrap?.offsetWidth || 0
const wrapHeight: number = wrap?.offsetHeight || 0
// 縮放位移坐標變化規律
// (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY
return {
pointX,
pointY
}
}
const handleLineMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
// 減去畫布偏移的距離(以畫布為基準進行計算坐標)
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.globalCompositeOperation = "source-over"
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
// 如果此時處于撤銷狀態,此時再使用畫筆,則將之后的狀態清空,以剛畫的作為最新的畫布狀態
if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
}
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(canvasCurrentHistory + 1)
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
const handleMoveMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const { current: fillStartPointX } = fillStartPointXRef
const { current: fillStartPointY } = fillStartPointYRef
if (!canvas || !wrap || mouseMode !== 0) return
// 為容器添加移動事件,可以在空白處移動圖片
wrap.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX
const moveY: number = event.pageY
translatePointXRef.current = fillStartPointX + (moveX - downX)
translatePointYRef.current = fillStartPointY + (moveY - downY)
canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
}
wrap.onmouseup = (event: MouseEvent) => {
const upX: number = event.pageX
const upY: number = event.pageY
wrap.onmousemove = null
wrap.onmouseup = null;
fillStartPointXRef.current = fillStartPointX + (upX - downX)
fillStartPointYRef.current = fillStartPointY + (upY - downY)
}
}
// 目前橡皮擦還有點問題,前端顯示正常,保存圖片下來,擦除的痕跡會變成白色
const handleEraserMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.globalCompositeOperation = "destination-out"
context.lineWidth = lineWidth
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
}
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(canvasCurrentHistory + 1)
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
const handleCanvas = () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context || !wrap) return
// 清除上一次設置的監聽,以防獲取參數錯誤
wrap.onmousedown = null
wrap.onmousedown = function (event: MouseEvent) {
const downX: number = event.pageX
const downY: number = event.pageY
switch (mouseMode) {
case MOVE_MODE:
handleMoveMode(downX, downY)
break
case LINE_MODE:
handleLineMode(downX, downY)
break
case ERASER_MODE:
handleEraserMode(downX, downY)
break
default:
break
}
}
wrap.onwheel = null
wrap.onwheel = (e: MouseWheelEvent) => {
const { deltaY } = e
const newScale: number = deltaY > 0
? (canvasScale * 10 - 0.1 * 10) / 10
: (canvasScale * 10 + 0.1 * 10) / 10
if (newScale < 0.1 || newScale > 2) return
setCanvasScale(newScale)
}
}
const handleScaleChange = (value: number) => {
setCanvasScale(value)
}
const handleLineWidthChange = (value: number) => {
setLineWidth(value)
}
const handleColorChange = (color: string) => {
setLineColor(color)
}
const handleMouseModeChange = (event: RadioChangeEvent) => {
const { target: { value } } = event
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
setmouseMode(value)
if (!canvas || !wrap) return
switch (value) {
case MOVE_MODE:
canvas.style.cursor = 'move'
wrap.style.cursor = 'move'
break
case LINE_MODE:
canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
case ERASER_MODE:
message.warning('橡皮擦功能尚未完善,保存圖片會出現錯誤')
canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
wrap.style.cursor = 'default'
break
default:
canvas.style.cursor = 'default'
wrap.style.cursor = 'default'
break
}
}
const handleSaveClick = () => {
const { current: canvas } = canvasRef
// 可存入數據庫或是直接生成圖片
console.log(canvas?.toDataURL())
}
const handlePaperChange = (value: string) => {
const fillImageList = {
'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
}
setFillImageSrc(fillImageList[value])
}
const handleRollBack = () => {
const isFirstHistory: boolean = canvasCurrentHistory === 1
if (isFirstHistory) return
setCanvasCurrentHistory(canvasCurrentHistory - 1)
}
const handleRollForward = () => {
const { current: canvasHistroyList } = canvasHistroyListRef
const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
if (isLastHistory) return
setCanvasCurrentHistory(canvasCurrentHistory + 1)
}
const handleClearCanvasClick = () => {
const { current: canvas } = canvasRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !context || canvasCurrentHistory === 0) return
// 清空畫布歷史
canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
setCanvasCurrentHistory(1)
message.success('畫布清除成功!')
}
return (
<div>
<CustomBreadcrumb list={['內容管理', '批閱作業']} />
<div className="mark-paper__container" ref={containerRef}>
<div className="mark-paper__wrap" ref={wrapRef}>
<div
className="mark-paper__mask"
style={{ display: isLoading ? 'flex' : 'none' }}
>
<Spin
tip="圖片加載中..."
indicator={<Icon type="loading" style={{ fontSize: 36 }} spin
/>}
/>
</div>
<canvas
ref={canvasRef}
className="mark-paper__canvas">
<p>很可惜,這個東東與您的電腦不搭!</p>
</canvas>
</div>
<div className="mark-paper__sider">
<div>
選擇作業:
<Select
defaultValue="xueshengjia"
style={{
width: '100%', margin: '10px 0 20px 0'
}}
onChange={handlePaperChange} >
<OptGroup label="17軟件一班">
<Option value="xueshengjia">學生甲</Option>
<Option value="xueshengyi">學生乙</Option>
</OptGroup>
<OptGroup label="17軟件二班">
<Option value="xueshengbing">學生丙</Option>
</OptGroup>
</Select>
</div>
<div>
畫布操作:<br />
<div className="mark-paper__action">
<Tooltip title="撤銷">
<i
className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`}
onClick={handleRollBack} />
</Tooltip>
<Tooltip title="恢復">
<i
className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`}
onClick={handleRollForward} />
</Tooltip>
<Popconfirm
title="確定清空畫布嗎?"
onConfirm={handleClearCanvasClick}
okText="確定"
cancelText="取消"
>
<Tooltip title="清空">
<i className="icon iconfont icon-qingchu" />
</Tooltip>
</Popconfirm>
</div>
</div>
<div>
畫布縮放:
<Tooltip placement="top" title='可用鼠標滾輪進行縮放'>
<Icon type="question-circle" />
</Tooltip>
<Slider
min={0.1}
max={2.01}
step={0.1}
value={canvasScale}
tipFormatter={(value) => `${(value).toFixed(2)}x`}
onChange={handleScaleChange} />
</div>
<div>
畫筆大小:
<Slider
min={1}
max={9}
value={lineWidth}
tipFormatter={(value) => `${value}px`}
onChange={handleLineWidthChange} />
</div>
<div>
模式選擇:
<Radio.Group
className="radio-group"
onChange={handleMouseModeChange}
value={mouseMode}>
<Radio value={0}>移動</Radio>
<Radio value={1}>畫筆</Radio>
<Radio value={2}>橡皮擦</Radio>
</Radio.Group>
</div>
<div>
顏色選擇:
<div className="color-picker__container">
{['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => {
return (
<Tooltip placement="top" title={color} key={color}>
<div
role="button"
className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`}
style={{ background: color }}
onClick={() => handleColorChange(color)}
/>
</Tooltip>
)
})}
</div>
</div>
<Button onClick={handleSaveClick}>保存圖片</Button>
</div>
</div>
</div >
)
}
export default MarkPaper as ComponentType
總結
到此這篇關于Html5 Canvas實現圖片標記、縮放、移動和保存歷史狀態 (附轉換公式)的文章就介紹到這了,更多相關Canvas 圖片標記 縮放 移動內容請搜索腳本之家以前的文章或繼續瀏覽下面的相關文章,希望大家以后多多支持腳本之家!