成人性生交大片免费看视频r_亚洲综合极品香蕉久久网_在线视频免费观看一区_亚洲精品亚洲人成人网在线播放_国产精品毛片av_久久久久国产精品www_亚洲国产一区二区三区在线播_日韩一区二区三区四区区区_亚洲精品国产无套在线观_国产免费www

主頁 > 知識庫 > Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能 (附轉(zhuǎn)換公式)

Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能 (附轉(zhuǎn)換公式)

熱門標(biāo)簽:黃石ai電銷機(jī)器人呼叫中心 高德地圖標(biāo)注商戶怎么標(biāo) ok電銷機(jī)器人 電話機(jī)器人技術(shù) 如何查看地圖標(biāo)注 地圖標(biāo)注軟件打印出來 智能電銷機(jī)器人被禁用了么 惡搞電話機(jī)器人 欣鼎電銷機(jī)器人 效果

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

前言

因為也是大三了,最近俺也在找實習(xí),之前有一個自己的小項目:

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

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

采用的是 canvas 繪制畫筆,由css3的 transform 屬性來進(jìn)行平移與縮放,之后再詳細(xì)介紹介紹

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

效果預(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

我們分別運(yùn)用公式算一下出來的坐標(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'
}

可能有人會問,為什么要減去這個 offsetLeftoffsetTop 呢,因為我們上面反復(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畫布填充圖像信息!

這樣就大功告成啦?。?!

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

總結(jié)

到此這篇關(guān)于Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài) (附轉(zhuǎn)換公式)的文章就介紹到這了,更多相關(guān)Canvas 圖片標(biāo)記 縮放 移動內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持腳本之家!

標(biāo)簽:綏化 阿壩 金昌 盤錦 聊城 赤峰 中山 萍鄉(xiāng)

巨人網(wǎng)絡(luò)通訊聲明:本文標(biāo)題《Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能 (附轉(zhuǎn)換公式)》,本文關(guān)鍵詞  Html5,Canvas,實現(xiàn),圖片,標(biāo)記,;如發(fā)現(xiàn)本文內(nèi)容存在版權(quán)問題,煩請?zhí)峁┫嚓P(guān)信息告之我們,我們將及時溝通與處理。本站內(nèi)容系統(tǒng)采集于網(wǎng)絡(luò),涉及言論、版權(quán)與本站無關(guān)。
  • 相關(guān)文章
  • 下面列出與本文章《Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能 (附轉(zhuǎn)換公式)》相關(guān)的同類信息!
  • 本頁收集關(guān)于Html5 Canvas實現(xiàn)圖片標(biāo)記、縮放、移動和保存歷史狀態(tài)功能 (附轉(zhuǎn)換公式)的相關(guān)信息資訊供網(wǎng)民參考!
  • 推薦文章
    日韩一区二区三区精品视频第3页| 国内少妇毛片视频| 一级二级三级视频| 国产人妻精品午夜福利免费| 91成人福利视频| 三年片观看免费观看大全视频下载| juliaann成人作品在线看| 精品国产一区二区精华| 九九久久免费视频| 国产又爽又黄又嫩又猛又粗| 2023国产一二三区日本精品2022| 91丨porny丨最新| av地址在线观看| 国产精品久久无码一三区| 一个人看的www视频免费在线观看| 国产精品扒开腿做爽爽爽软件| 日韩**一区毛片| 亚洲精品一区二区三区99| 亚洲欧美清纯在线制服| 亚洲国产精品无码av| 寂寞护士中文字幕mp4| 亚洲国产精品精华液网站| 97超碰人人看| 日韩在线观看网址| 99精品久久只有精品| 欧美极品jizzhd欧美仙踪林| 欧美久久一二三四区| 久久国产直播| 一区二区三区资源| 亚洲3atv精品一区二区三区| 白嫩亚洲一区二区三区| 国产91精品露脸国语对白| 蜜臀av性久久久久蜜臀av麻豆| 亚洲一区二区3| 精品国产一二| 日韩精品一级毛片在线播放| 国产成人一区二区三区影院| 黄色资源在线观看| 欧美日韩精品亚洲精品| 五月天综合网| 国产精品成人一区二区| 免费黄色欧美视频| 在线视频 91| 欧美日韩中文| 天天在线免费视频| 成人一区二区三区视频| 久久精品色综合| 黄色av免费在线观看| 一区二区视频国产| 导航艳情国产电影| 天天综合狠狠精品| 亚洲 另类 春色 国产| 亚洲高清免费一级二级三级| 91国语精品自产拍在线观看性色| 欧美日本国产在线| 亚洲第一av在线| 国产精品免费一区二区三区都可以| 亚洲国模精品一区| 国产精品第12页| 久久精品视频一区| 九色porn蝌蚪| 在线免费看黄网站| 性高潮视频在线观看| 天海翼女教师无删减版电影| 福利视频在线| 国产精品美女久久久久久久久久久| 久久久久av| 91免费看`日韩一区二区| 99国产精品| 亚洲一区二区动漫| 日韩av免费在线播放| 99国产在线播放| 又黄又骚的视频| 国产九九在线视频| 黄瓜视频在线免费观看| 天天成人综合网| 99国产精品久久久久99打野战| 国产精品进线69影院| 欧美gv在线观看| 久久综合九色综合网站| 亚洲乱码国产一区三区| 国产精品久久久久久久| 国产精品国产三级国产普通话99| 中国黄色在线视频| 成人性色生活片| 国产又黄又粗又长| 国产.欧美.日韩| av影音资源网| 日本成人免费视频| 福利h视频在线| 久久久久亚洲av无码专区体验| 2022亚洲天堂| 欧美人与物videos另类| 精品国产欧美一区二区| 国产一区二区在线观看免费| 亚洲三级电影在线观看| 好男人社区在线视频| 日韩成人av网| 最近高清中文在线字幕在线观看| 美女桃色网站| 国产美女视频免费观看下载软件| 佐佐木明希av| a中文在线播放| 免费毛片在线播放免费| 97色伦亚洲国产| 亚洲欧洲av一区二区三区久久| 成人在线影视| 原创国产精品91| 日韩一区二区免费电影| 久草视频在线观| 三上悠亚一区二区| 亚洲欧美日韩在线观看a三区| 亚洲欧美综合乱码精品成人网| 骚虎黄色影院| 亚洲品质自拍| 亚洲欧美精品中文第三| 国产精品区一区二区三在线播放| 亚洲国产日韩欧美| 国产成人av影院| 一级二级三级在线观看| 亚洲电影免费观看高清完整版在线| 精品国语对白精品自拍视| 欧美激情 亚洲a∨综合| 国产精品乱子乱xxxx| 图片区 小说区 区 亚洲五月| 麻豆tv在线| 欧美老女人另类| 久久人体做爰大胆| 99久久久国产精品无码免费| 国产一级二级在线| 2019一级黄色毛片免费看网| 天堂影视av| 99re66热这里只有精品3直播| 欧美—级在线免费片| 日韩成人在线一区| 色婷婷综合视频在线观看| 51久久精品夜色国产麻豆| 91玉足脚交白嫩脚丫在线播放| 极品校花啪啪激情久久| 婷婷国产在线综合| 国产精品99久久久久久久vr| 国产伦精品一区二区三区照片91| 自由日本语热亚洲人| 久久人人97超碰精品888| 精品国产免费视频| 国产一精品一aⅴ一免费| 亚洲精品中文字幕乱码三区91| 美女的胸无遮挡在线观看| 久久人体做爰大胆| 手机在线观看日韩av| 一区二区三区视频免费看| 91超碰中文字幕久久精品| 欧洲在线一区| 日本高清视频在线播放| 国模私拍一区二区国模曼安| 亚洲色图18p| 一级日本不卡的影视| 无码一区二区三区在线| 色愁久久久久久| 欧美日韩一区二区三区在线播放| 成人黄在线观看| 午夜精品福利在线观看| 99久re热视频这里只有精品6| 天堂在线观看视频| 亚洲精品国产首次亮相| 日韩禁在线播放| 日韩一区二区精品葵司在线| 国产精品久久夜| 国产一起色一起爱| 亚洲欧美在线播放| 欧美aa在线观看| 污网站在线看| jizz在线观看视频| 欧美 日韩 国产 成人 在线观看| 久久视频www| 精品久久久影院| 黄色片在线观看免费| 国产肉体ⅹxxx137大胆| 亚洲成人激情小说| 女同久久另类69精品国产| 丰满大乳少妇在线观看网站| 亚洲网在线观看| 日本成人在线免费视频| 亚洲综合av在线播放| 欧美最猛性xxxxx亚洲精品| 日韩精品乱码av一区二区| 91丨九色丨国产| 亚洲精品免费在线| 欧美日韩国产综合视频在线观看| aa片在线观看视频在线播放| 亚洲国产va精品久久久不卡综合| 91精品电影| 亚洲婷婷在线| 2020日本不卡一区二区视频| 免费欧美电影| 中文字幕在线网| 国产乱淫av一区二区三区| 国产精品91一区二区三区| 日本免费高清一区二区| 乱人伦xxxx国语对白| 69av影院| 日本18视频网站| a级片在线免费| 亚洲欧美精品久久| 97欧美在线视频| 青青草视频国产| 在线一区二区三区四区| 国产精品欧美亚洲| 337p日本欧洲亚洲大胆鲁鲁| 影音先锋男人每日资源站| 国产乱子伦视频一区二区三区| 最新国产中文字幕| 黄网站在线观看高清免费| av网站无病毒在线| 久久久精品国产免费观看同学| 国产精品美女黄网| 国产在线视精品麻豆| 亚洲aⅴ乱码精品成人区| 精精国产xxxx视频在线| 亚洲视频日本| 亚洲最大福利网站| 四虎成人在线观看| 77777影视视频在线观看| www.8ⅹ8ⅹ羞羞漫画在线看| 国产精品久久久久永久免费观看| 欧美午夜激情小视频| 揄拍成人国产精品视频| 午夜影院在线| 岛国av免费观看| 国产精品久久精品牛牛影视| 116美女写真午夜一级久久| 涩涩视频在线观看| 一区二区三区在线看| 宅男噜噜噜66国产免费观看| www.偷拍.com| 91丝袜高跟美女视频| 亚洲亚洲精品三区日韩精品在线视频| 一区二区在线观看网站| 欧美福利电影在线观看| 亚洲3atv精品一区二区三区| 国产中文在线视频| 欧美福利视频一区二区| fc2ppv在线观看| 欧美国产日韩亚洲一区| 香蕉成人在线视频| 精品国产影院| 亚洲天堂网av在线| 久久精品国产一区二区电影| 又色又爽又黄视频| 97色婷婷成人综合在线观看| 视频一区视频二区中文| 男操女视频网站| 亚洲视频一区二区免费在线观看| 国产91精品入| 强开小嫩苞一区二区三区视频| 九九九免费视频| 国产高清无密码一区二区三区| 国产又粗又长视频| 五月婷六月丁香| 一区二区不卡在线视频 午夜欧美不卡'| 国产精品igao网网址不卡| 日韩第一页在线观看| 日韩在线中文字幕| 欧美午夜电影在线观看| 亚洲ww精品| 亚洲h精品动漫在线观看| 在线观看精品视频| 欧美日韩国产一级| 国产一级一区二区| 成人影院在线视频| 91成人福利在线观看| 国产精品一色哟哟哟| 美女视频a黄免费| 亚洲图片123| 亚洲男人都懂的网站| 2023av视频| 久久精品免费一区二区三区| 国产91精品一区二区麻豆网站| 韩国三级hd两男一女| 色婷婷综合五月| 在线观看免费视频你懂的| 一本大道久久a久久精品综合| 色激情天天射综合网| 欧美午夜不卡| 久久99亚洲精品| 国产精品jizz在线观看老狼| 欧美一区二区三区四区夜夜大片| 交换国产精品视频一区| 91精品国产综合久久久久久久久久| 中文字幕在线网址| 免费观看的黄色网址| av片在线观看| 91麻豆精品国产91久久久久久| 波多野结衣二区三区| 天海翼在线播放| 26uuu亚洲国产精品| 中文在线最新版天堂| 免费黄网在线看| 另类视频在线观看| 欧美激情视频网址| 91美女片黄在线观看游戏| 操人视频在线观看| 国产日产精品一区二区三区的介绍| 中国女人内谢25xxxx免费视频| 欧美白嫩的18sex少妇| 久久久影视传媒| 欧美成人手机在线视频| 精品精品国产毛片在线看| 国产成人精选| 男人的天堂久久久| 中文字幕在线一区免费| 性伦欧美刺激片在线观看| 精品福利一区二区| 欧美日韩夫妻久久| 国产精品高潮久久| fc2ppv在线观看| 日本黄色激情视频| 激情懂色av一区av二区av| 国产欧美精品一区二区三区介绍| 欧美不卡在线观看| 日韩电影大片中文字幕| 精品视频1区2区| 久草在线在线视频| 精品久久久久久中文字幕一区奶水| 亚洲一区二区三区| 91蜜桃在线观看| 毛片网站免费观看| 波多野结衣一区二区三区四区|