您好,登錄后才能下訂單哦!
這篇文章主要介紹Html5 Canvas如何實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能,文中介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們一定要看完!
效果預(yù)覽
動圖是放cdn的,如果訪問不了,可以登錄在線嘗試嘗試: test.algbb.cn/#/admin/con…
公式推導(dǎo) 如果不想看公式如何推導(dǎo),可以直接跳過看后面的具體實現(xiàn)~ 1. 坐標(biāo)轉(zhuǎn)換公式 轉(zhuǎn)換公式介紹
其實一開始也是想在網(wǎng)上找一下有沒有相關(guān)的資料,但是可惜找不到,所以就自己慢慢的推出來了。我就舉一下橫坐標(biāo)的例子吧!
通用公式
這個公式是表示,通過公式來將鼠標(biāo)按下的坐標(biāo)轉(zhuǎn)換為畫布中的相對坐標(biāo),這一點尤為重要
(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
參數(shù)解釋
transformOrigin: transform變化的基點(通過這個屬性來控制元素以哪里進(jìn)行變化)
downX: 鼠標(biāo)按下的坐標(biāo)(注意,用的時候需要減去容器左偏移距離,因為我們要的是相對于容器的坐標(biāo))
scale: 縮放倍數(shù),默認(rèn)為1
translateX: 平移的距離
推導(dǎo)過程
這個公式的話,其實就比較通用,可以用在別的利用到 transform
屬性的場景,至于怎么推導(dǎo)的話,我是用的笨辦法
具體的測試代碼,放在文末,需要自取~
1. 先做出兩個相同的元素,然后標(biāo)記上坐標(biāo),并且設(shè)置容器屬性 overflow:hidden
來隱藏溢出內(nèi)容
ok,現(xiàn)在就有兩個一樣的矩陣?yán)玻覀優(yōu)樗麡?biāo)記上一些紅點,然后我們對左邊的進(jìn)行css3的樣式變化 transform
矩形的寬高是 360px * 360px
的,我們定義一下他的變化屬性,變化基點選擇正中心,放大3倍
// css transform-origin: 180px 180px; transform: scale(3, 3);
得到如下結(jié)果
ok,我們現(xiàn)在對比一下上面的結(jié)果,就會發(fā)現(xiàn),放大3倍的時候,恰好是中間黑色方塊占據(jù)了全部寬度。接下來我們就可以對這些點與原先沒有進(jìn)行變化(右邊)的矩形進(jìn)行對比就可以得到他們坐標(biāo)的關(guān)系啦
2. 開始對兩個坐標(biāo)進(jìn)行對比,然后推出公式
現(xiàn)在舉一個簡單的例子吧,例如我們算一下左上角的坐標(biāo)(現(xiàn)在已經(jīng)標(biāo)記為黃色了)
其實我們其實就可以直接心算出來坐標(biāo)的關(guān)系啦
( 這里左邊計算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo) )
( 這里左邊計算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo) )
( 這里左邊計算坐標(biāo)的值是我們鼠標(biāo)按下的坐標(biāo) )
因為寬高是 360px
,所以分成3等份,每份寬度是 120px
因為變化之后容器的寬高是不變的,變化的只有矩形本身
我們可以得出左邊的黃色標(biāo)記坐標(biāo)是 x:120 y:0
,右邊的黃色標(biāo)記為 x:160 y:120
(這個其實肉眼看應(yīng)該就能看出來了,實在不行可以用紙筆算一算)
這個坐標(biāo)可能有點特殊,我們再換幾個來計算計算(根據(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)系了,我們可以列一個表
還覺得不放心?我們可以換一下,縮放倍數(shù)與容器寬高等進(jìn)行計算
不知道大家有沒有感覺呢,然后我們就可以慢慢根據(jù)坐標(biāo)推出通用的公式啦
(transformOrigin - downX) / scale * (scale-1) + down - translateX = point
當(dāng)然,我們或許還有這個 translateX
沒有嘗試,這個就比較簡單一點了,腦內(nèi)模擬一下,就知道我們可以減去位移的距離就ok啦。我們測試一下
我們先修改一下樣式,新增一下位移的距離
transform-origin: 180px 180px; transform: scale(3, 3) translate(-40px,-40px);
還是我們上面的狀態(tài),ok,我們現(xiàn)在藍(lán)色跟綠色的標(biāo)記還是一一對應(yīng)的,那我們看看現(xiàn)在的坐標(biāo)情況
藍(lán)色:左邊: x:0 y:0
,右邊: x:160 y:160
綠色:左邊: x:120 y:120
,右邊: x:200 y:200
我們分別運用公式算一下出來的坐標(biāo)是怎么樣的 (以下為經(jīng)過坐標(biāo)換算)
藍(lán)色:左邊: x:120 y:120
,右邊: x:160 y:160
綠色:左邊: x:160 y:160
,右邊: x:200 y:200
不難發(fā)現(xiàn),我們其實就相差了與位移距離 translateX/translateY
的差值,所以,我們只需要減去位移的距離就可以完美的進(jìn)行坐標(biāo)轉(zhuǎn)換啦
測試公式
根據(jù)上面的公式,我們可以簡單測試一下!這個公式到底能不能生效?。?!
我們直接沿用上面的demo,測試一下如果元素進(jìn)行了變化,我們鼠標(biāo)點下的地方生成一個標(biāo)記,位置是否顯示正確??雌饋砗躱k啊(手動滑稽)
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
呢,因為我們上面反復(fù)強(qiáng)調(diào),我們計算的是鼠標(biāo)點擊的坐標(biāo),而這個坐標(biāo)還是相對于我們展示容器的坐標(biāo),所以我們要減去容器本身的偏移量才行。
組件設(shè)計
既然demo啥的都已經(jīng)測試了ok了,我們接下來就逐一分析一下這個組件應(yīng)該咋設(shè)計好呢(目前仍為低配版,之后再進(jìn)行優(yōu)化完善)
1. 基本的畫布構(gòu)成
我們先簡單分析一下這個構(gòu)成吧,其實主要就是一個畫布的容器,右邊一個工具欄,僅此而已
大體就這樣子啦!
<div className="mark-paper__wrap" ref={wrapRef}> <canvas ref={canvasRef} className="mark-paper__canvas"> <p>很可惜,這個東東與您的電腦不搭!</p> </canvas> <div className="mark-paper__sider" /> </div>
我們唯一需要的一點就是,容器需要設(shè)置屬性 overflow: hidden
用來隱藏內(nèi)部canvas畫布溢出的內(nèi)容,也就是說,我們要控制我們可視的區(qū)域。同時我們需要動態(tài)獲取容器寬高來為canvas設(shè)置尺寸
2. 初始化canvas畫布與填充圖片
我們可以弄個方法來初始化并且填充畫布,以下截取主要部分,其實就是為canvas畫布設(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è)置變化基點,為畫布容器中央 canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px` // 清除上一次變化的效果 canvas.style.transform = '' } }
3. 監(jiān)聽canvas畫布的各種鼠標(biāo)事件
這個控制移動的話,我們首先可以弄一個方法來監(jiān)聽畫布鼠標(biāo)的各種事件,可以區(qū)分不同的模式來進(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)聽,以防獲取參數(shù)錯誤 wrap.onmousedown = null wrap.onmousedown = function (event: MouseEvent) { const downX: number = event.pageX const downY: number = event.pageY // 區(qū)分我們現(xiàn)在選擇的鼠標(biāo)模式:移動、畫筆、橡皮擦 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. 實現(xiàn)畫布移動
這個就比較好辦啦,我們只需要利用鼠標(biāo)按下的坐標(biāo),和我們拖動的距離就可以實現(xiàn)畫布的移動啦,因為涉及到每次移動都需要計算最新的位移距離,我們可以定義幾個變量來進(jìn)行計算。
這里監(jiān)聽的是容器的鼠標(biāo)事件,而不是canvas畫布的事件,因為這樣子我們可以再移動超過邊界的時候也可以進(jìn)行移動操作
簡單的總結(jié)一下:
傳入鼠標(biāo)按下的坐標(biāo)
計算當(dāng)前位移距離,并更新css變化效果
鼠標(biāo)抬起時更新最新的位移狀態(tài)
// 定義一些變量,來保存當(dāng)前/最新的移動狀態(tài) // 當(dāng)前位移的距離 const translatePointXRef: MutableRefObject<number> = useRef(0) const translatePointYRef: MutableRefObject<number> = useRef(0) // 上一次位移結(jié)束的位移距離 const fillStartPointXRef: MutableRefObject<number> = useRef(0) const fillStartPointYRef: MutableRefObject<number> = useRef(0) // 移動時候的監(jiān)聽函數(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 // 為容器添加移動事件,可以在空白處移動圖片 wrap.onmousemove = (event: MouseEvent) => { const moveX: number = event.pageX const moveY: number = event.pageY // 更新現(xiàn)在的位移距離,值為:上一次位移結(jié)束的坐標(biāo)+移動的距離 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 // 取消事件監(jiān)聽 wrap.onmousemove = null wrap.onmouseup = null; // 鼠標(biāo)抬起時候,更新“上一次唯一結(jié)束的坐標(biāo)” fillStartPointXRef.current = fillStartPointX + (upX - downX) fillStartPointYRef.current = fillStartPointY + (upY - downY) } }
5. 實現(xiàn)畫布縮放
畫布縮放我主要通過右側(cè)的滑動條以及鼠標(biāo)滾輪來實現(xiàn),首先我們再監(jiān)聽畫布鼠標(biāo)事件的函數(shù)中加一下監(jiān)聽滾輪的事件
總結(jié)一下:
監(jiān)聽鼠標(biāo)滾輪的變化
更新縮放倍數(shù),并改變樣式
// 監(jiān)聽鼠標(biāo)滾輪,更新畫布縮放倍數(shù) 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) } } // 監(jiān)聽滑動條來控制縮放 <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的副作用函數(shù),依賴于畫布縮放倍數(shù)來進(jìn)行樣式的更新
//監(jiān)聽縮放畫布 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. 實現(xiàn)畫筆繪制
這個就需要用到我們之前推導(dǎo)出來的公式啦!因為呢,仔細(xì)想一下,如果我們縮放位移之后,我們鼠標(biāo)按下的位置,他的坐標(biāo)可能就相對于畫布來說會有變化, 所以我們需要轉(zhuǎn)換一下才能進(jìn)行鼠標(biāo)按下的位置與畫布的位置一一對應(yīng)的效果
稍微總結(jié)一下:
傳入鼠標(biāo)按下的坐標(biāo)
通過公式轉(zhuǎn)換,開始在對應(yīng)坐標(biāo)下繪制
鼠標(biāo)抬起時,取消事件監(jiān)聽
// 利用公式轉(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)聽鼠標(biāo)畫筆事件 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 // 減去畫布偏移的距離(以畫布為基準(zhǔn)進(jìn)行計算坐標(biāo)) downX = downX - offsetLeft downY = downY - offsetTop const { pointX, pointY } = generateLinePoint(downX, downY) context.globalCompositeOperation = "source-over" context.beginPath() // 設(shè)置畫筆起點 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. 橡皮擦的實現(xiàn)
橡皮擦目前還有點問題,現(xiàn)在的話是通過將 canvas
畫布的背景圖片 + globalCompositeOperation
這個屬性來模擬橡皮擦的實現(xiàn),不過,這時候圖片生成出來之后,橡皮擦的痕跡會變成白色,而不是透明
此步驟與畫筆實現(xiàn)差不多,只有一點點小變動
設(shè)置屬性 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. 撤銷與恢復(fù)的功能實現(xiàn)
這個的話,我們首先需要了解常見的撤銷與恢復(fù)的功能的邏輯 分幾種情況吧
若當(dāng)前狀態(tài)處于第一個位置,則不允許撤銷
若當(dāng)前狀態(tài)處于最后一個位置,則不允許恢復(fù)
如果當(dāng)前撤銷了,然而更新了狀態(tài),則取當(dāng)前狀態(tài)為最新的狀態(tài)(也就是說不允許恢復(fù)了,這個剛更新的狀態(tài)就是最新的)
畫布狀態(tài)的更新
所以我們需要設(shè)置一些變量來存,狀態(tài)列表,與當(dāng)前畫筆的狀態(tài)下標(biāo)
// 定義參數(shù)存東東 const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([]) const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
我們還需要在初始化canvas的時候,我們就添加入當(dāng)前的狀態(tài)存入列表中,作為最先開始的空畫布狀態(tài)
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) } }
然后我們就實現(xiàn)一下,畫筆更新時候,我們也需要將當(dāng)前的狀態(tài)添加入 畫筆狀態(tài)列表 ,并且更新當(dāng)前狀態(tài)對應(yīng)的下標(biāo),還需要處理一下一些細(xì)節(jié)
總結(jié)一下:
鼠標(biāo)抬起時,獲取當(dāng)前canvas畫布狀態(tài)
添加進(jìn)狀態(tài)列表中,并且更新狀態(tài)下標(biāo)
如果當(dāng)前處于撤銷狀態(tài),若使用畫筆更新狀態(tài),則將當(dāng)前的最為最新的狀態(tài),原先位置之后的狀態(tài)全部清空
const handleLineMode = (downX: number, downY: number) => { // 省略一萬字... canvas.onmouseup = () => { const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height) // 如果此時處于撤銷狀態(tài),此時再使用畫筆,則將之后的狀態(tài)清空,以剛畫的作為最新的畫布狀態(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 } }
畫布狀態(tài)的撤銷與恢復(fù)
ok,其實現(xiàn)在關(guān)于畫布狀態(tài)的更新,我們已經(jīng)完成了。接下來我們需要處理一下狀態(tài)的撤銷與恢復(fù)的功能啦
我們先定義一下這個工具欄吧
然后我們設(shè)置對應(yīng)的事件,分別是撤銷,恢復(fù),與清空,其實都很容易看懂,最多就是處理一下邊界情況。
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 // 清空畫布?xì)v史 canvasHistroyListRef.current = [canvasHistroyListRef.current[0]] setCanvasCurrentHistory(1) message.success('畫布清除成功!') }
事件設(shè)置好之后,我們就可以開始監(jiān)聽一下這個 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畫布填充圖像信息!
這樣就大功告成啦!?。?/p>
9. 實現(xiàn)鼠標(biāo)圖標(biāo)的變化
我們簡單的處理一下,畫筆模式則是畫筆的圖標(biāo),橡皮擦模式下鼠標(biāo)是橡皮擦,移動模式下就是普通的移動圖標(biāo)
切換模式時候,設(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('橡皮擦功能尚未完善,保存圖片會出現(xiàn)錯誤') 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)在的話只是一個demo狀態(tài),通過點擊選擇框,切換不同的圖片
// 重置變換參數(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]) }
注意事項
注意容器的偏移量
我們需要注意一下,因為公式中的 downX
是相對容器的坐標(biāo),也就是說,我們需要減去容器的偏移量,這種情況會出現(xiàn)在使用了 margin
等參數(shù),或者說上方或者左側(cè)有別的元素的情況
我們輸出一下我們紅色的元素的 offsetLeft
等屬性,會發(fā)現(xiàn)他是已經(jīng)本身就有50的偏移量了,我們計算鼠標(biāo)點擊的坐標(biāo)的時候就要減去這一部分的偏移量
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相對布局的情況
假如我們現(xiàn)在有一種這種的布局,打印紅色元素的偏移量,看起來都挺正常的
但是如果我們目標(biāo)元素的父元素(也就是黃色部分)設(shè)置 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>
這時候我們打印出來的偏移量會是多少呢
兩次答案不一樣啊,因為我們的偏移量是根據(jù)相對位置來計算的,如果父容器使用相對布局,則會影響我們子元素的偏移量
組件代碼(低配版)
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') }, []) // 重置變換參數(shù),重新繪制圖片 useEffect(() => { setIsLoading(true) translatePointXRef.current = 0 translatePointYRef.current = 0 fillStartPointXRef.current = 0 fillStartPointYRef.current = 0 setCanvasScale(1) fillImage() }, [fillImageSrc]) // 畫布參數(shù)變動時,重新監(jiān)聽canvas useEffect(() => { handleCanvas() }, [mouseMode, canvasScale, canvasCurrentHistory]) // 監(jiān)聽畫筆顏色變化 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)聽縮放畫布 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è)置為圖片,橡皮擦的效果才能出來 canvas.style.background = `url(${img.src})` context.drawImage(img, 0, 0) context.strokeStyle = lineColor context.lineWidth = lineWidth context.lineJoin = 'round' context.lineCap = 'round' // 設(shè)置變化基點,為畫布容器中央 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 // 減去畫布偏移的距離(以畫布為基準(zhǔn)進(jìn)行計算坐標(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) // 如果此時處于撤銷狀態(tài),此時再使用畫筆,則將之后的狀態(tài)清空,以剛畫的作為最新的畫布狀態(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 // 為容器添加移動事件,可以在空白處移動圖片 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 // 清除上一次設(shè)置的監(jiān)聽,以防獲取參數(shù)錯誤 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('橡皮擦功能尚未完善,保存圖片會出現(xiàn)錯誤') 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ù)庫或是直接生成圖片 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 // 清空畫布?xì)v史 canvasHistroyListRef.current = [canvasHistroyListRef.current[0]] setCanvasCurrentHistory(1) message.success('畫布清除成功!') } return ( <div> <CustomBreadcrumb list={['內(nèi)容管理', '批閱作業(yè)']} /> <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> 選擇作業(yè): <Select defaultValue="xueshengjia" style={{ width: '100%', margin: '10px 0 20px 0' }} onChange={handlePaperChange} > <OptGroup label="17軟件一班"> <Option value="xueshengjia">學(xué)生甲</Option> <Option value="xueshengyi">學(xué)生乙</Option> </OptGroup> <OptGroup label="17軟件二班"> <Option value="xueshengbing">學(xué)生丙</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="恢復(fù)"> <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='可用鼠標(biāo)滾輪進(jìn)行縮放'> <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如何實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能”這篇文章的所有內(nèi)容,感謝各位的閱讀!希望分享的內(nèi)容對大家有幫助,更多相關(guān)知識,歡迎關(guān)注億速云行業(yè)資訊頻道!
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。