亚洲香蕉成人av网站在线观看_欧美精品成人91久久久久久久_久久久久久久久久久亚洲_热久久视久久精品18亚洲精品_国产精自产拍久久久久久_亚洲色图国产精品_91精品国产网站_中文字幕欧美日韩精品_国产精品久久久久久亚洲调教_国产精品久久一区_性夜试看影院91社区_97在线观看视频国产_68精品久久久久久欧美_欧美精品在线观看_国产精品一区二区久久精品_欧美老女人bb

首頁 > 開發 > HTML5 > 正文

Html5 Canvas實現圖片標記、縮放、移動和保存歷史狀態功能 (附轉換公式)

2024-09-05 07:23:19
字體:
來源:轉載
供稿:網友

哈哈哈俺又來啦,這次帶來的是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倍

// csstransform-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?。ㄊ謩踊?/p>

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

可能有人會問,為什么要減去這個 offsetLeftoffsetTop 呢,因為我們上面反復強調,我們計算的是鼠標點擊的坐標,而這個坐標還是相對于我們展示容器的坐標,所以我們要減去容器本身的偏移量才行。

組件設計

既然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 & FormComponentPropsconst 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 圖片標記 縮放 移動內容請搜索武林網以前的文章或繼續瀏覽下面的相關文章,希望大家以后多多支持武林網!

發表評論 共有條評論
用戶名: 密碼:
驗證碼: 匿名發表
亚洲香蕉成人av网站在线观看_欧美精品成人91久久久久久久_久久久久久久久久久亚洲_热久久视久久精品18亚洲精品_国产精自产拍久久久久久_亚洲色图国产精品_91精品国产网站_中文字幕欧美日韩精品_国产精品久久久久久亚洲调教_国产精品久久一区_性夜试看影院91社区_97在线观看视频国产_68精品久久久久久欧美_欧美精品在线观看_国产精品一区二区久久精品_欧美老女人bb
欧美激情性做爰免费视频| 欧美性生交xxxxxdddd| 亚洲人a成www在线影院| 26uuu久久噜噜噜噜| 欧美日韩国产一区在线| 国产在线精品一区免费香蕉| 亚洲精品99久久久久| 亚洲国产精品热久久| 黑人与娇小精品av专区| 亚洲精品一区久久久久久| 69av在线视频| 一本色道久久综合狠狠躁篇的优点| 国产成人综合亚洲| 国产视频丨精品|在线观看| 日本一区二区在线播放| 亚洲美女av电影| 中文字幕自拍vr一区二区三区| 在线a欧美视频| 亚洲国产精品专区久久| 亚洲成人激情小说| 亚洲成人av在线| 欧美老妇交乱视频| www.欧美三级电影.com| 亚洲丝袜一区在线| 欧美国产日韩免费| 亚洲成人黄色网| 久久精彩免费视频| 91网站在线免费观看| 欧美激情综合色综合啪啪五月| 欧美在线激情视频| 欧美日韩国产区| 久久精品成人动漫| 亚洲第一在线视频| 国产精品久久久久久超碰| 精品亚洲国产成av人片传媒| 中文字幕成人精品久久不卡| 国产一区二区免费| 国产精品久久久久久av| 国产美女主播一区| 久久综合亚洲社区| 国产精品免费一区二区三区都可以| 成人国内精品久久久久一区| 国模gogo一区二区大胆私拍| 亚洲欧美国产va在线影院| 亚洲国产高清福利视频| 川上优av一区二区线观看| 久久久精品免费视频| 2023亚洲男人天堂| 久久婷婷国产麻豆91天堂| 日韩欧美在线免费观看| 国产精品精品国产| 亚洲精品国产精品自产a区红杏吧| 成人精品网站在线观看| 亚洲色图综合久久| 亚洲高清一区二| 久久av.com| 欧美日在线观看| 91精品视频在线免费观看| 亚洲欧美日韩中文在线制服| 国产日本欧美一区| 日本一区二区三区四区视频| 亚洲大胆人体视频| 91久久精品一区| 69久久夜色精品国产7777| 国产91在线高潮白浆在线观看| 日韩精品中文字幕在线| 欧美性猛交视频| 国产剧情日韩欧美| 欧美视频在线看| 欧美精品福利在线| 欧美日本中文字幕| 中文字幕久热精品在线视频| 午夜美女久久久久爽久久| 色婷婷av一区二区三区久久| 久久久久久九九九| 亚洲精品视频二区| 中文字幕亚洲欧美日韩在线不卡| 亚洲综合一区二区不卡| 日韩中文在线不卡| 91亚洲精品久久久| 日本欧美爱爱爱| 青青草国产精品一区二区| 亚洲三级黄色在线观看| 亚洲精品电影在线观看| 午夜精品国产精品大乳美女| 久久久精品久久久| 日本精品免费一区二区三区| 亚洲品质视频自拍网| 国外成人免费在线播放| 国产精品第2页| 欧美黑人国产人伦爽爽爽| 欧美日韩亚洲精品内裤| 欧美性xxxxxxxxx| 国产精品视频一区二区三区四| 欧美大片免费观看在线观看网站推荐| 这里只有视频精品| 亚洲第一精品福利| 精品视频中文字幕| 久久精品91久久香蕉加勒比| 久久久精品国产一区二区| 91久热免费在线视频| 国产精品久久久久9999| 亚洲成av人影院在线观看| 成人h视频在线观看播放| 亚洲国产欧美一区二区三区同亚洲| 国产一区二区动漫| 九九综合九九综合| 国产精品一区二区久久| 国产精品亚洲аv天堂网| 日韩电影在线观看免费| 久久人人爽人人爽人人片av高清| 国产日韩av高清| 国产91对白在线播放| 欧美日韩爱爱视频| 免费91在线视频| 亚洲精品国产综合区久久久久久久| 欧美黑人xxxx| 国产精品视频久久久久| 国产精品精品视频一区二区三区| 色婷婷综合久久久久中文字幕1| 一本大道香蕉久在线播放29| 亚洲久久久久久久久久| 成人免费在线视频网址| 久久久电影免费观看完整版| 日韩国产一区三区| 一本一本久久a久久精品牛牛影视| 欧美亚洲免费电影| 久久精品亚洲精品| 亚洲欧美一区二区三区四区| 国产999精品久久久| 精品magnet| 欧美日韩中文字幕在线| 在线观看欧美视频| 日韩精品在线电影| 成人亚洲激情网| 久久这里只有精品99| 久久精品91久久香蕉加勒比| 精品女厕一区二区三区| 亚洲欧美日韩天堂| 色系列之999| 日韩成人av在线播放| 国内精品视频久久| 日韩精品在线视频| 精品中文视频在线| 亚洲精品视频免费| 欧美专区第一页| 中文字幕精品—区二区| 91色在线视频| 福利二区91精品bt7086| 亚洲va久久久噜噜噜| 国产91精品青草社区| 日本精品久久久| 国外成人在线视频| 欧美日韩一区免费| 亚洲一区二区三区香蕉| 国产噜噜噜噜噜久久久久久久久| 92国产精品视频| 欧美中文字幕在线| 国产自产女人91一区在线观看| 国产一区二区三区直播精品电影| 97精品久久久中文字幕免费| 亚洲国产精品va在看黑人| 色一情一乱一区二区| 成人精品久久一区二区三区|