日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 前端技术 > CSS >内容正文

CSS

canvas换图时候会闪烁_Canvas实现图片上标注、缩放、移动和保存历史状态,纯干货(附CSS 3变化公式)...

發(fā)布時(shí)間:2023/12/1 CSS 39 豆豆
生活随笔 收集整理的這篇文章主要介紹了 canvas换图时候会闪烁_Canvas实现图片上标注、缩放、移动和保存历史状态,纯干货(附CSS 3变化公式)... 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

(給前端大學(xué)加星標(biāo),提升前端技能.)

作者:zhcxk1998

https://juejin.im/user/5d4304bdf265da03d15531dc

哈哈哈俺又來(lái)啦,這次帶來(lái)的是canvas實(shí)現(xiàn)一些畫(huà)布功能的文章,希望大家喜歡!這個(gè)css3變化公式可以適用于平常我們使用的transform屬性或者是移動(dòng)端我們縮放地圖啊之類的都可以喲!

前言

因?yàn)橐彩谴笕?#xff0c;最近俺也在找實(shí)習(xí),之前有一個(gè)自己的小項(xiàng)目:

https://github.com/zhcxk1998/School-Partners

面試官說(shuō)可以往深層次思考一下,或許加一些新的功能來(lái)增加項(xiàng)目的難度,他提了幾個(gè)建議,其中一個(gè)就是試卷在線批閱,老師可以在上面對(duì)作業(yè)進(jìn)行批注,圈圈點(diǎn)點(diǎn)等俺當(dāng)天晚上就開(kāi)始研究這個(gè)東東哈哈哈,終于被我研究出來(lái)啦!

采用的是canvas繪制畫(huà)筆,由css3的transform屬性來(lái)進(jìn)行平移與縮放,因?yàn)槟乜紤]到如果用canvas的drawImage或者scale等屬性進(jìn)行變化,生成出來(lái)的圖片也會(huì)有影響,想著直接css3變化,canvas用來(lái)做畫(huà)筆等功能。大佬們有何妙招,在評(píng)論區(qū)指點(diǎn)指點(diǎn)!

(希望大家可以留下寶貴的贊與star嘻嘻)

效果預(yù)覽

動(dòng)圖是放cdn的,如果訪問(wèn)不了,可以登錄在線嘗試嘗試:http://test.algbb.cn/#/admin/content/mark-paper

公式推導(dǎo)

如果不想看公式如何推導(dǎo),可以直接跳過(guò)看后面的具體實(shí)現(xiàn)~

1.坐標(biāo)轉(zhuǎn)換公式

轉(zhuǎn)換公式介紹

其實(shí)一開(kāi)始也是想在網(wǎng)上找一下有沒(méi)有相關(guān)的資料,但是可惜找不到,所以就自己慢慢的推出來(lái)了。我就舉一下橫坐標(biāo)的例子吧!

通用公式

這個(gè)公式是表示,通過(guò)公式來(lái)將鼠標(biāo)按下的坐標(biāo)轉(zhuǎn)換為畫(huà)布中的相對(duì)坐標(biāo),這一點(diǎn)尤為重要

(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX

參數(shù)解釋

transformOrigin: transform變化的基點(diǎn)(通過(guò)這個(gè)屬性來(lái)控制元素以哪里進(jìn)行變化)downX: 鼠標(biāo)按下的坐標(biāo)(注意,用的時(shí)候需要減去容器左偏移距離,因?yàn)槲覀円氖窍鄬?duì)于容器的坐標(biāo))scale: 縮放倍數(shù),默認(rèn)為1translateX: 平移的距離

推導(dǎo)過(guò)程

這個(gè)公式的話,其實(shí)就比較通用,可以用在別的利用到transform屬性的場(chǎng)景,至于怎么推導(dǎo)的話,我是用的笨辦法

具體的測(cè)試代碼,放在文末,需要自取~

1. 先做出兩個(gè)相同的元素,然后標(biāo)記上坐標(biāo),并且設(shè)置容器屬性overflow:hidden來(lái)隱藏溢出內(nèi)容

ok,現(xiàn)在就有兩個(gè)一樣的矩陣?yán)?#xff0c;我們?yōu)樗麡?biāo)記上一些紅點(diǎn),然后我們對(duì)左邊的進(jìn)行css3的樣式變化transform

矩形的寬高是360px * 360px的,我們定義一下他的變化屬性,變化基點(diǎn)選擇正中心,放大3倍

// csstransform-origin: 180px 180px;transform: scale(3, 3);

得到如下結(jié)果

ok,我們現(xiàn)在對(duì)比一下上面的結(jié)果,就會(huì)發(fā)現(xiàn),放大3倍的時(shí)候,恰好是中間黑色方塊占據(jù)了全部寬度。接下來(lái)我們就可以對(duì)這些點(diǎn)與原先沒(méi)有進(jìn)行變化(右邊)的矩形進(jìn)行對(duì)比就可以得到他們坐標(biāo)的關(guān)系啦

2. 開(kāi)始對(duì)兩個(gè)坐標(biāo)進(jìn)行對(duì)比,然后推出公式

現(xiàn)在舉一個(gè)簡(jiǎn)單的例子吧,例如我們算一下左上角的坐標(biāo)(現(xiàn)在已經(jīng)標(biāo)記為黃色了)

其實(shí)我們其實(shí)就可以直接心算出來(lái)坐標(biāo)的關(guān)系啦
(這里左邊計(jì)算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo))
(這里左邊計(jì)算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo))
(這里左邊計(jì)算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo))

  • 因?yàn)閷捀呤?60px,所以分成3等份,每份寬度是120px

  • 因?yàn)樽兓笕萜鞯膶捀呤遣蛔兊?#xff0c;變化的只有矩形本身

  • 我們可以得出左邊的黃色標(biāo)記坐標(biāo)是x:120 y:0,右邊的黃色標(biāo)記為x:160 y:120(這個(gè)其實(shí)肉眼看應(yīng)該就能看出來(lái)了,實(shí)在不行可以用紙筆算一算)

這個(gè)坐標(biāo)可能有點(diǎn)特殊,我們?cè)贀Q幾個(gè)來(lái)計(jì)算計(jì)算(根據(jù)特殊推一般)

  • 藍(lán)色標(biāo)記:左邊:x:120 y:120,右邊:x: 160 y:160

  • 綠色標(biāo)記:左邊:x: 240 y:240,右邊:x: 200: y:200

好了,我們差不多已經(jīng)可以拿到坐標(biāo)之間的關(guān)系了,我們可以列一個(gè)表

還覺(jué)得不放心?我們可以換一下,縮放倍數(shù)與容器寬高等進(jìn)行計(jì)算

不知道大家有沒(méi)有感覺(jué)呢,然后我們就可以慢慢根據(jù)坐標(biāo)推出通用的公式啦

(transformOrigin - downX) / scale * (scale-1) + down - translateX = point

當(dāng)然,我們或許還有這個(gè)translateX沒(méi)有嘗試,這個(gè)就比較簡(jiǎn)單一點(diǎn)了,腦內(nèi)模擬一下,就知道我們可以減去位移的距離就ok啦。我們測(cè)試一下

我們先修改一下樣式,新增一下位移的距離

transform-origin: 180px 180px;transform: scale(3, 3) translate(-40px,-40px);

還是我們上面的狀態(tài),ok,我們現(xiàn)在藍(lán)色跟綠色的標(biāo)記還是一一對(duì)應(yīng)的,那我們看看現(xiàn)在的坐標(biāo)情況

  • 藍(lán)色:左邊:x:0 y:0,右邊:x:160 y:160

  • 綠色:左邊:x:120 y:120,右邊:x:200 y:200

我們分別運(yùn)用公式算一下出來(lái)的坐標(biāo)是怎么樣的(以下為經(jīng)過(guò)坐標(biāo)換算)

  • 藍(lán)色:左邊:x:120 y:120,右邊:x:160 y:160

  • 綠色:左邊:x:160 y:160,右邊:x:200 y:200

不難發(fā)現(xiàn),我們其實(shí)就相差了與位移距離translateX/translateY的差值,所以,我們只需要減去位移的距離就可以完美的進(jìn)行坐標(biāo)轉(zhuǎn)換啦

測(cè)試公式

根據(jù)上面的公式,我們可以簡(jiǎn)單測(cè)試一下!這個(gè)公式到底能不能生效!!!

我們直接沿用上面的demo,測(cè)試一下如果元素進(jìn)行了變化,我們鼠標(biāo)點(diǎn)下的地方生成一個(gè)標(biāo)記,位置是否顯示正確。看起來(lái)很ok啊(手動(dòng)滑稽)

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'}

可能有人會(huì)問(wèn),為什么要減去這個(gè)offsetLeft跟offsetTop呢,因?yàn)槲覀兩厦娣磸?fù)強(qiáng)調(diào),我們計(jì)算的是鼠標(biāo)點(diǎn)擊的坐標(biāo),而這個(gè)坐標(biāo)還是相對(duì)于我們展示容器的坐標(biāo),所以我們要減去容器本身的偏移量才行。

組件設(shè)計(jì)

既然demo啥的都已經(jīng)測(cè)試了ok了,我們接下來(lái)就逐一分析一下這個(gè)組件應(yīng)該咋設(shè)計(jì)好呢(目前仍為低配版,之后再進(jìn)行優(yōu)化完善)

1. 基本的畫(huà)布構(gòu)成

我們先簡(jiǎn)單分析一下這個(gè)構(gòu)成吧,其實(shí)主要就是一個(gè)畫(huà)布的容器,右邊一個(gè)工具欄,僅此而已

大體就這樣子啦!

ref={canvasRef} className="mark-paper__canvas">

很可惜,這個(gè)東東與您的電腦不搭!

我們唯一需要的一點(diǎn)就是,容器需要設(shè)置屬性overflow: hidden用來(lái)隱藏內(nèi)部canvas畫(huà)布溢出的內(nèi)容,也就是說(shuō),我們要控制我們可視的區(qū)域。同時(shí)我們需要?jiǎng)討B(tài)獲取容器寬高來(lái)為canvas設(shè)置尺寸

2. 初始化canvas畫(huà)布與填充圖片

我們可以弄個(gè)方法來(lái)初始化并且填充畫(huà)布,以下截取主要部分,其實(shí)就是為canvas畫(huà)布設(shè)置尺寸與填充我們的圖片

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) // 設(shè)置變化基點(diǎn),為畫(huà)布容器中央 canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px` // 清除上一次變化的效果 canvas.style.transform = '' }}

3. 監(jiān)聽(tīng)canvas畫(huà)布的各種鼠標(biāo)事件

這個(gè)控制移動(dòng)的話,我們首先可以弄一個(gè)方法來(lái)監(jiān)聽(tīng)畫(huà)布鼠標(biāo)的各種事件,可以區(qū)分不同的模式來(lái)進(jìn)行不同的事件處理

const handleCanvas = () => { const { current: canvas } = canvasRef const { current: wrap } = wrapRef const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d') if (!context || !wrap) return // 清除上一次設(shè)置的監(jiān)聽(tīng),以防獲取參數(shù)錯(cuò)誤 wrap.onmousedown = null wrap.onmousedown = function (event: MouseEvent) { const downX: number = event.pageX const downY: number = event.pageY // 區(qū)分我們現(xiàn)在選擇的鼠標(biāo)模式:移動(dòng)、畫(huà)筆、橡皮擦 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. 實(shí)現(xiàn)畫(huà)布移動(dòng)

這個(gè)就比較好辦啦,我們只需要利用鼠標(biāo)按下的坐標(biāo),和我們拖動(dòng)的距離就可以實(shí)現(xiàn)畫(huà)布的移動(dòng)啦,因?yàn)樯婕暗矫看我苿?dòng)都需要計(jì)算最新的位移距離,我們可以定義幾個(gè)變量來(lái)進(jìn)行計(jì)算。

這里監(jiān)聽(tīng)的是容器的鼠標(biāo)事件,而不是canvas畫(huà)布的事件,因?yàn)檫@樣子我們可以再移動(dòng)超過(guò)邊界的時(shí)候也可以進(jìn)行移動(dòng)操作

簡(jiǎn)單的總結(jié)一下:

  • 傳入鼠標(biāo)按下的坐標(biāo)

  • 計(jì)算當(dāng)前位移距離,并更新css變化效果

  • 鼠標(biāo)抬起時(shí)更新最新的位移狀態(tài)

// 定義一些變量,來(lái)保存當(dāng)前/最新的移動(dòng)狀態(tài)// 當(dāng)前位移的距離const translatePointXRef: MutableRefObject = useRef(0)const translatePointYRef: MutableRefObject = useRef(0)// 上一次位移結(jié)束的位移距離const fillStartPointXRef: MutableRefObject = useRef(0)const fillStartPointYRef: MutableRefObject = useRef(0)// 移動(dòng)時(shí)候的監(jiān)聽(tīng)函數(shù)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 // 為容器添加移動(dòng)事件,可以在空白處移動(dòng)圖片 wrap.onmousemove = (event: MouseEvent) => { const moveX: number = event.pageX const moveY: number = event.pageY // 更新現(xiàn)在的位移距離,值為:上一次位移結(jié)束的坐標(biāo)+移動(dòng)的距離 translatePointXRef.current = fillStartPointX + (moveX - downX) translatePointYRef.current = fillStartPointY + (moveY - downY) // 更新畫(huà)布的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 // 取消事件監(jiān)聽(tīng) wrap.onmousemove = null wrap.onmouseup = null; // 鼠標(biāo)抬起時(shí)候,更新“上一次唯一結(jié)束的坐標(biāo)” fillStartPointXRef.current = fillStartPointX + (upX - downX) fillStartPointYRef.current = fillStartPointY + (upY - downY) }}

5. 實(shí)現(xiàn)畫(huà)布縮放

畫(huà)布縮放我主要通過(guò)右側(cè)的滑動(dòng)條以及鼠標(biāo)滾輪來(lái)實(shí)現(xiàn),首先我們?cè)俦O(jiān)聽(tīng)畫(huà)布鼠標(biāo)事件的函數(shù)中加一下監(jiān)聽(tīng)滾輪的事件

總結(jié)一下:

  • 監(jiān)聽(tīng)鼠標(biāo)滾輪的變化

  • 更新縮放倍數(shù),并改變樣式

// 監(jiān)聽(tīng)鼠標(biāo)滾輪,更新畫(huà)布縮放倍數(shù)const handleCanvas = () => { const { current: wrap } = wrapRef // 省略一萬(wàn)字... wrap.onwheel = null wrap.onwheel = (e: MouseWheelEvent) => { const { deltaY } = e // 這里要注意一下,我是0.1來(lái)遞增遞減,但是因?yàn)镴S使用IEEE 754,來(lái)計(jì)算,所以精度有問(wèn)題,我們自己處理一下 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) }}// 監(jiān)聽(tīng)滑動(dòng)條來(lái)控制縮放 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的副作用函數(shù),依賴于畫(huà)布縮放倍數(shù)來(lái)進(jìn)行樣式的更新

//監(jiān)聽(tīng)縮放畫(huà)布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. 實(shí)現(xiàn)畫(huà)筆繪制

這個(gè)就需要用到我們之前推導(dǎo)出來(lái)的公式啦!因?yàn)槟?#xff0c;仔細(xì)想一下,如果我們縮放位移之后,我們鼠標(biāo)按下的位置,他的坐標(biāo)可能就相對(duì)于畫(huà)布來(lái)說(shuō)會(huì)有變化,所以我們需要轉(zhuǎn)換一下才能進(jìn)行鼠標(biāo)按下的位置與畫(huà)布的位置一一對(duì)應(yīng)的效果

稍微總結(jié)一下:

  • 傳入鼠標(biāo)按下的坐標(biāo)

  • 通過(guò)公式轉(zhuǎn)換,開(kāi)始在對(duì)應(yīng)坐標(biāo)下繪制

  • 鼠標(biāo)抬起時(shí),取消事件監(jiān)聽(tīng)

// 利用公式轉(zhuǎn)換一下坐標(biāo)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 // 縮放位移坐標(biāo)變化規(guī)律 // (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 }}// 監(jiān)聽(tīng)鼠標(biāo)畫(huà)筆事件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 // 減去畫(huà)布偏移的距離(以畫(huà)布為基準(zhǔn)進(jìn)行計(jì)算坐標(biāo)) downX = downX - offsetLeft downY = downY - offsetTop const { pointX, pointY } = generateLinePoint(downX, downY) context.globalCompositeOperation = "source-over" context.beginPath() // 設(shè)置畫(huà)筆起點(diǎn) 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) // 開(kāi)始繪制畫(huà)筆線條~ context.lineTo(pointX, pointY) context.stroke() } canvas.onmouseup = () => { context.closePath() canvas.onmousemove = null canvas.onmouseup = null }}

7. 橡皮擦的實(shí)現(xiàn)

橡皮擦目前還有點(diǎn)問(wèn)題,現(xiàn)在的話是通過(guò)將canvas畫(huà)布的背景圖片 + globalCompositeOperation這個(gè)屬性來(lái)模擬橡皮擦的實(shí)現(xiàn),不過(guò),這時(shí)候圖片生成出來(lái)之后,橡皮擦的痕跡會(huì)變成白色,而不是透明

此步驟與畫(huà)筆實(shí)現(xiàn)差不多,只有一點(diǎn)點(diǎn)小變動(dòng)

  • 設(shè)置屬性context.globalCompositeOperation = "destination-out"

// 目前橡皮擦還有點(diǎn)問(wèn)題,前端顯示正常,保存圖片下來(lái),擦除的痕跡會(huì)變成白色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. 撤銷與恢復(fù)的功能實(shí)現(xiàn)

這個(gè)的話,我們首先需要了解常見(jiàn)的撤銷與恢復(fù)的功能的邏輯分幾種情況吧

  • 若當(dāng)前狀態(tài)處于第一個(gè)位置,則不允許撤銷

  • 若當(dāng)前狀態(tài)處于最后一個(gè)位置,則不允許恢復(fù)

  • 如果當(dāng)前撤銷了,然而更新了狀態(tài),則取當(dāng)前狀態(tài)為最新的狀態(tài)(也就是說(shuō)不允許恢復(fù)了,這個(gè)剛更新的狀態(tài)就是最新的)

畫(huà)布狀態(tài)的更新

所以我們需要設(shè)置一些變量來(lái)存,狀態(tài)列表,與當(dāng)前畫(huà)筆的狀態(tài)下標(biāo)

// 定義參數(shù)存東東const canvasHistroyListRef: MutableRefObject = useRef([])const [canvasCurrentHistory, setCanvasCurrentHistory] = useState(0)

我們還需要在初始化canvas的時(shí)候,我們就添加入當(dāng)前的狀態(tài)存入列表中,作為最先開(kāi)始的空畫(huà)布狀態(tài)

const fillImage = async () => { // 省略一萬(wàn)字... 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) }}

然后我們就實(shí)現(xiàn)一下,畫(huà)筆更新時(shí)候,我們也需要將當(dāng)前的狀態(tài)添加入畫(huà)筆狀態(tài)列表,并且更新當(dāng)前狀態(tài)對(duì)應(yīng)的下標(biāo),還需要處理一下一些細(xì)節(jié)

總結(jié)一下:

  • 鼠標(biāo)抬起時(shí),獲取當(dāng)前canvas畫(huà)布狀態(tài)

  • 添加進(jìn)狀態(tài)列表中,并且更新?tīng)顟B(tài)下標(biāo)

  • 如果當(dāng)前處于撤銷狀態(tài),若使用畫(huà)筆更新?tīng)顟B(tài),則將當(dāng)前的最為最新的狀態(tài),原先位置之后的狀態(tài)全部清空

const handleLineMode = (downX: number, downY: number) => { // 省略一萬(wàn)字... canvas.onmouseup = () => { const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height) // 如果此時(shí)處于撤銷狀態(tài),此時(shí)再使用畫(huà)筆,則將之后的狀態(tài)清空,以剛畫(huà)的作為最新的畫(huà)布狀態(tài) 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 }}

畫(huà)布狀態(tài)的撤銷與恢復(fù)

ok,其實(shí)現(xiàn)在關(guān)于畫(huà)布狀態(tài)的更新,我們已經(jīng)完成了。接下來(lái)我們需要處理一下?tīng)顟B(tài)的撤銷與恢復(fù)的功能啦

我們先定義一下這個(gè)工具欄吧

然后我們?cè)O(shè)置對(duì)應(yīng)的事件,分別是撤銷,恢復(fù),與清空,其實(shí)都很容易看懂,最多就是處理一下邊界情況。

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 // 清空畫(huà)布?xì)v史 canvasHistroyListRef.current = [canvasHistroyListRef.current[0]] setCanvasCurrentHistory(1) message.success('畫(huà)布清除成功!')}

事件設(shè)置好之后,我們就可以開(kāi)始監(jiān)聽(tīng)一下這個(gè)canvasCurrentHistory當(dāng)前狀態(tài)下標(biāo),使用副作用函數(shù)進(jìn)行處理

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畫(huà)布填充圖像信息!

這樣就大功告成啦!!!

9. 實(shí)現(xiàn)鼠標(biāo)圖標(biāo)的變化

我們簡(jiǎn)單的處理一下,畫(huà)筆模式則是畫(huà)筆的圖標(biāo),橡皮擦模式下鼠標(biāo)是橡皮擦,移動(dòng)模式下就是普通的移動(dòng)圖標(biāo)

切換模式時(shí)候,設(shè)置一下不同的圖標(biāo)

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('橡皮擦功能尚未完善,保存圖片會(huì)出現(xiàn)錯(cuò)誤') 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. 切換圖片

現(xiàn)在的話只是一個(gè)demo狀態(tài),通過(guò)點(diǎn)擊選擇框,切換不同的圖片

// 重置變換參數(shù),重新繪制圖片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])}

注意事項(xiàng)

注意容器的偏移量

我們需要注意一下,因?yàn)楣街械膁ownX是相對(duì)容器的坐標(biāo),也就是說(shuō),我們需要減去容器的偏移量,這種情況會(huì)出現(xiàn)在使用了margin等參數(shù),或者說(shuō)上方或者左側(cè)有別的元素的情況

我們輸出一下我們紅色的元素的offsetLeft等屬性,會(huì)發(fā)現(xiàn)他是已經(jīng)本身就有50的偏移量了,我們計(jì)算鼠標(biāo)點(diǎn)擊的坐標(biāo)的時(shí)候就要減去這一部分的偏移量

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;}

注意父組件使用relative相對(duì)布局的情況

假如我們現(xiàn)在有一種這種的布局,打印紅色元素的偏移量,看起來(lái)都挺正常的

但是如果我們目標(biāo)元素的父元素(也就是黃色部分)設(shè)置relative相對(duì)布局

.wrap { position: relative; width: 400px; height: 300px; background: yellow;}

這時(shí)候我們打印出來(lái)的偏移量會(huì)是多少呢

兩次答案不一樣啊,因?yàn)槲覀兊钠屏渴歉鶕?jù)相對(duì)位置來(lái)計(jì)算的,如果父容器使用相對(duì)布局,則會(huì)影響我們子元素的偏移量

組件代碼(低配版)

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 & FormComponentPropsconst MarkPaper: FC = (props: MarkPaperProps) => { const MOVE_MODE: number = 0 const LINE_MODE: number = 1 const ERASER_MODE: number = 2 const canvasRef: RefObject = useRef(null) const containerRef: RefObject = useRef(null) const wrapRef: RefObject = useRef(null) const translatePointXRef: MutableRefObject = useRef(0) const translatePointYRef: MutableRefObject = useRef(0) const fillStartPointXRef: MutableRefObject = useRef(0) const fillStartPointYRef: MutableRefObject = useRef(0) const canvasHistroyListRef: MutableRefObject = useRef([]) const [lineColor, setLineColor] = useState('#fa4b2a') const [fillImageSrc, setFillImageSrc] = useState('') const [mouseMode, setmouseMode] = useState(MOVE_MODE) const [lineWidth, setLineWidth] = useState(5) const [canvasScale, setCanvasScale] = useState(1) const [isLoading, setIsLoading] = useState(false) const [canvasCurrentHistory, setCanvasCurrentHistory] = useState(0) useEffect(() => { setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg') }, []) // 重置變換參數(shù),重新繪制圖片 useEffect(() => { setIsLoading(true) translatePointXRef.current = 0 translatePointYRef.current = 0 fillStartPointXRef.current = 0 fillStartPointYRef.current = 0 setCanvasScale(1) fillImage() }, [fillImageSrc]) // 畫(huà)布參數(shù)變動(dòng)時(shí),重新監(jiān)聽(tīng)canvas useEffect(() => { handleCanvas() }, [mouseMode, canvasScale, canvasCurrentHistory]) // 監(jiān)聽(tīng)畫(huà)筆顏色變化 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]) //監(jiān)聽(tīng)縮放畫(huà)布 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 // 背景設(shè)置為圖片,橡皮擦的效果才能出來(lái) canvas.style.background = `url(${img.src})` context.drawImage(img, 0, 0) context.strokeStyle = lineColor context.lineWidth = lineWidth context.lineJoin = 'round' context.lineCap = 'round' // 設(shè)置變化基點(diǎn),為畫(huà)布容器中央 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 // 縮放位移坐標(biāo)變化規(guī)律 // (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 // 減去畫(huà)布偏移的距離(以畫(huà)布為基準(zhǔn)進(jìn)行計(jì)算坐標(biāo)) 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) // 如果此時(shí)處于撤銷狀態(tài),此時(shí)再使用畫(huà)筆,則將之后的狀態(tài)清空,以剛畫(huà)的作為最新的畫(huà)布狀態(tài) 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 // 為容器添加移動(dòng)事件,可以在空白處移動(dòng)圖片 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) } } // 目前橡皮擦還有點(diǎn)問(wèn)題,前端顯示正常,保存圖片下來(lái),擦除的痕跡會(huì)變成白色 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 // 清除上一次設(shè)置的監(jiān)聽(tīng),以防獲取參數(shù)錯(cuò)誤 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('橡皮擦功能尚未完善,保存圖片會(huì)出現(xiàn)錯(cuò)誤') 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 // 可存入數(shù)據(jù)庫(kù)或是直接生成圖片 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 // 清空畫(huà)布?xì)v史 canvasHistroyListRef.current = [canvasHistroyListRef.current[0]] setCanvasCurrentHistory(1) message.success('畫(huà)布清除成功!') } return ( className="mark-paper__mask" style={{ display: isLoading ? 'flex' : 'none' }} > tip="圖片加載中..." indicator={ />} /> ref={canvasRef} className="mark-paper__canvas">

很可惜,這個(gè)東東與您的電腦不搭!

選擇作業(yè): defaultValue="xueshengjia" style={{ width: '100%', margin: '10px 0 20px 0' }} onChange={handlePaperChange} > 學(xué)生甲 學(xué)生乙 學(xué)生丙 畫(huà)布操作: className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`} onClick={handleRollBack} /> className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`} onClick={handleRollForward} /> onConfirm={handleClearCanvasClick} okText="確定" cancelText="取消" > 畫(huà)布縮放: min={0.1} max={2.01} step={0.1} value={canvasScale} tipFormatter={(value) => `${(value).toFixed(2)}x`} onChange={handleScaleChange} /> 畫(huà)筆大小: min={1} max={9} value={lineWidth} tipFormatter={(value) => `${value}px`} onChange={handleLineWidthChange} /> 模式選擇: className="radio-group" onChange={handleMouseModeChange} value={mouseMode}> 移動(dòng) 畫(huà)筆 橡皮擦 顏色選擇: {['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => { return ( role="button" className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`} style={{ background: color }} onClick={() => handleColorChange(color)} /> ) })} 保存圖片 )}export default MarkPaper as ComponentType

結(jié)語(yǔ)

如果這篇東東對(duì)大家有所幫助,希望大家可以給我點(diǎn)贊一下鼓勵(lì)一下!

或者給俺的項(xiàng)目點(diǎn)個(gè)star支持支持吧!

github.com/zhcxk1998/School-Partners

菜雞分析的不到位,還請(qǐng)各位大佬指出俺的不足!阿里嘎多~

分享前端好文,點(diǎn)亮?在看?

總結(jié)

以上是生活随笔為你收集整理的canvas换图时候会闪烁_Canvas实现图片上标注、缩放、移动和保存历史状态,纯干货(附CSS 3变化公式)...的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。