• 企业400电话
  • 微网小程序
  • AI电话机器人
  • 电商代运营
  • 全 部 栏 目

    企业400电话 网络优化推广 AI电话机器人 呼叫中心 网站建设 商标✡知产 微网小程序 电商运营 彩铃•短信 增值拓展业务
    Html5 Canvas实现图片标记、缩放、移动和保存历史状态功能 (附转换公式)

    哈哈哈俺又来啦,这次带来的是canvas实现一些画布功能的文章,希望大家喜欢!

    前言

    因为也是大三了,最近俺也在找实习,之前有一个自己的小项目:

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

    面试官说可以往深层次思考一下,或许加一些新的功能来增加项目的难度,他提了几个建议,其中一个就是 试卷在线批阅,老师可以在上面对作业进行批注,圈圈点点等 俺当天晚上就开始研究这个东东哈哈哈,终于被我研究出来啦!

    采用的是 canvas 绘制画笔,由css3的 transform 属性来进行平移与缩放,之后再详细介绍介绍

    (希望大家可以留下宝贵的赞与star嘻嘻)

    效果预览

    动图是放cdn的,如果访问不了,可以登录在线尝试尝试: test.algbb.cn/#/admin/con…

    公式推导 如果不想看公式如何推导,可以直接跳过看后面的具体实现~ 1. 坐标转换公式 转换公式介绍

    其实一开始也是想在网上找一下有没有相关的资料,但是可惜找不到,所以就自己慢慢的推出来了。我就举一下横坐标的例子吧!

    通用公式

    这个公式是表示,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要

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

    参数解释

    transformOrigin: transform变化的基点(通过这个属性来控制元素以哪里进行变化)
    downX: 鼠标按下的坐标(注意,用的时候需要减去容器左偏移距离,因为我们要的是相对于容器的坐标)
    scale: 缩放倍数,默认为1
    translateX: 平移的距离

    推导过程

    这个公式的话,其实就比较通用,可以用在别的利用到 transform 属性的场景,至于怎么推导的话,我是用的笨办法

    具体的测试代码,放在文末,需要自取~

    1. 先做出两个相同的元素,然后标记上坐标,并且设置容器属性 overflow:hidden 来隐藏溢出内容

    ok,现在就有两个一样的矩阵啦,我们为他标记上一些红点,然后我们对左边的进行css3的样式变化 transform

    矩形的宽高是 360px * 360px 的,我们定义一下他的变化属性,变化基点选择正中心,放大3倍

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

    得到如下结果

    ok,我们现在对比一下上面的结果,就会发现,放大3倍的时候,恰好是中间黑色方块占据了全部宽度。接下来我们就可以对这些点与原先没有进行变化(右边)的矩形进行对比就可以得到他们坐标的关系啦

    2. 开始对两个坐标进行对比,然后推出公式

    现在举一个简单的例子吧,例如我们算一下左上角的坐标(现在已经标记为黄色了)

    其实我们其实就可以直接心算出来坐标的关系啦

    这里左边计算坐标的值是我们鼠标按下的坐标

    这里左边计算坐标的值是我们鼠标按下的坐标

    这里左边计算坐标的值是我们鼠标按下的坐标

    这个坐标可能有点特殊,我们再换几个来计算计算(根据特殊推一般)

    蓝色标记:左边: 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:120 y:120 ,右边: x:160 y:160 绿色:左边: x:160 y:160 ,右边: x:200 y:200

    不难发现,我们其实就相差了与位移距离 translateX/translateY 的差值,所以,我们只需要减去位移的距离就可以完美的进行坐标转换啦

    测试公式

    根据上面的公式,我们可以简单测试一下!这个公式到底能不能生效!!!

    我们直接沿用上面的demo,测试一下如果元素进行了变化,我们鼠标点下的地方生成一个标记,位置是否显示正确。看起来很ok啊(手动滑稽)

    const wrap = document.getElementById('wrap')
    wrap.onmousedown = function (e) {
      const downX = e.pageX - wrap.offsetLeft
      const downY = e.pageY - wrap.offsetTop
    
      const scale = 3
      const translateX = -40
      const translateY = -40
      const transformOriginX = 180
      const transformOriginY = 180
    
      const dot = document.getElementById('dot')
      dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'
      dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'
    }

    可能有人会问,为什么要减去这个 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画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作

    简单的总结一下:

    // 定义一些变量,来保存当前/最新的移动状态
    // 当前位移的距离
    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)
      }
    }

    然后我们就实现一下,画笔更新时候,我们也需要将当前的状态添加入 画笔状态列表 ,并且更新当前状态对应的下标,还需要处理一下一些细节

    总结一下:

    const handleLineMode = (downX: number, downY: number) => {
      // 省略一万字...
      canvas.onmouseup = () => {
        const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
    
        // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态
        if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
          canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
        }
        canvasHistroyListRef.current.push(imageData)
        setCanvasCurrentHistory(canvasCurrentHistory + 1)
        context.closePath()
        canvas.onmousemove = null
        canvas.onmouseup = null
      }
    }

    画布状态的撤销与恢复

    ok,其实现在关于画布状态的更新,我们已经完成了。接下来我们需要处理一下状态的撤销与恢复的功能啦

    我们先定义一下这个工具栏吧

    然后我们设置对应的事件,分别是撤销,恢复,与清空,其实都很容易看懂,最多就是处理一下边界情况。

    const handleRollBack = () => {
      const isFirstHistory: boolean = canvasCurrentHistory === 1
      if (isFirstHistory) return
      setCanvasCurrentHistory(canvasCurrentHistory - 1)
    }
    
    const handleRollForward = () => {
      const { current: canvasHistroyList } = canvasHistroyListRef
      const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
      if (isLastHistory) return
      setCanvasCurrentHistory(canvasCurrentHistory + 1)
    }
    
    const handleClearCanvasClick = () => {
      const { current: canvas } = canvasRef
      const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
      if (!canvas || !context || canvasCurrentHistory === 0) return
    
      // 清空画布历史
      canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
      setCanvasCurrentHistory(1)
    
      message.success('画布清除成功!')
    }

    事件设置好之后,我们就可以开始监听一下这个 canvasCurrentHistory 当前状态下标,使用副作用函数进行处理

    useEffect(() => {
      const { current: canvas } = canvasRef
      const { current: canvasHistroyList } = canvasHistroyListRef
      const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
      if (!canvas || !context || canvasCurrentHistory === 0) return
      context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
    }, [canvasCurrentHistory])

    为canvas画布填充图像信息!

    这样就大功告成啦!!!

    9. 实现鼠标图标的变化

    我们简单的处理一下,画笔模式则是画笔的图标,橡皮擦模式下鼠标是橡皮擦,移动模式下就是普通的移动图标

    切换模式时候,设置一下不同的图标

    const handleMouseModeChange = (event: RadioChangeEvent) => {
      const { target: { value } } = event
      const { current: canvas } = canvasRef
      const { current: wrap } = wrapRef
    
      setmouseMode(value)
    
      if (!canvas || !wrap) return
      switch (value) {
        case MOVE_MODE:
          canvas.style.cursor = 'move'
          wrap.style.cursor = 'move'
          break
        case LINE_MODE:
          canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
          wrap.style.cursor = 'default'
          break
        case ERASER_MODE:
          message.warning('橡皮擦功能尚未完善,保存图片会出现错误')
          canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
          wrap.style.cursor = 'default'
          break
        default:
          canvas.style.cursor = 'default'
          wrap.style.cursor = 'default'
          break
      }
    }

    10. 切换图片

    现在的话只是一个demo状态,通过点击选择框,切换不同的图片

    // 重置变换参数,重新绘制图片
    useEffect(() => {
      setIsLoading(true)
      translatePointXRef.current = 0
      translatePointYRef.current = 0
      fillStartPointXRef.current = 0
      fillStartPointYRef.current = 0
      setCanvasScale(1)
      fillImage()
    }, [fillImageSrc])
    
    const handlePaperChange = (value: string) => {
      const fillImageList = {
        'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
        'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
        'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
      }
      setFillImageSrc(fillImageList[value])
    }

    注意事项

    注意容器的偏移量

    我们需要注意一下,因为公式中的 downX 是相对容器的坐标,也就是说,我们需要减去容器的偏移量,这种情况会出现在使用了 margin 等参数,或者说上方或者左侧有别的元素的情况

    我们输出一下我们红色的元素的 offsetLeft 等属性,会发现他是已经本身就有50的偏移量了,我们计算鼠标点击的坐标的时候就要减去这一部分的偏移量

    window.onload = function () {
      const test = document.getElementById('test')
      console.log(`offsetLeft: ${test.offsetLeft}, offsetHeight: ${test.offsetTop}`)
    }
    
    html,
    body {
      margin: 0;
      padding: 0;
    }
    
    #test {
      width: 50px;
      height: 50px;
      margin-left: 50px;
      background: red;
    }
    
    <div class="container">
      <div id="test"></div>
    </div>

    注意父组件使用relative相对布局的情况

    假如我们现在有一种这种的布局,打印红色元素的偏移量,看起来都挺正常的

    但是如果我们目标元素的父元素(也就是黄色部分)设置 relative 相对布局

    .wrap {
      position: relative;
      width: 400px;
      height: 300px;
      background: yellow;
    }
    
    <div class="container">
      <div class="sider"></div>
      <div class="wrap">
        <div id="test"></div>
      </div>
    </div>

    这时候我们打印出来的偏移量会是多少呢

    两次答案不一样啊,因为我们的偏移量是根据相对位置来计算的,如果父容器使用相对布局,则会影响我们子元素的偏移量

    组件代码(低配版)

    import React, { FC, ComponentType, useEffect, useRef, RefObject, useState, MutableRefObject } from 'react'
    import { CustomBreadcrumb } from '@/admin/components'
    import { RouteComponentProps } from 'react-router-dom';
    import { FormComponentProps } from 'antd/lib/form';
    import {
      Slider, Radio, Button, Tooltip, Icon, Select, Spin, message, Popconfirm
    } from 'antd';
    
    import './index.scss'
    import { RadioChangeEvent } from 'antd/lib/radio';
    import { getURLBase64 } from '@/admin/utils/getURLBase64'
    
    const { Option, OptGroup } = Select;
    
    type MarkPaperProps = RouteComponentProps & FormComponentProps
    
    const MarkPaper: FC<MarkPaperProps> = (props: MarkPaperProps) => {
      const MOVE_MODE: number = 0
      const LINE_MODE: number = 1
      const ERASER_MODE: number = 2
      const canvasRef: RefObject<HTMLCanvasElement> = useRef(null)
      const containerRef: RefObject<HTMLDivElement> = useRef(null)
      const wrapRef: RefObject<HTMLDivElement> = useRef(null)
      const translatePointXRef: MutableRefObject<number> = useRef(0)
      const translatePointYRef: MutableRefObject<number> = useRef(0)
      const fillStartPointXRef: MutableRefObject<number> = useRef(0)
      const fillStartPointYRef: MutableRefObject<number> = useRef(0)
      const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
      const [lineColor, setLineColor] = useState<string>('#fa4b2a')
      const [fillImageSrc, setFillImageSrc] = useState<string>('')
      const [mouseMode, setmouseMode] = useState<number>(MOVE_MODE)
      const [lineWidth, setLineWidth] = useState<number>(5)
      const [canvasScale, setCanvasScale] = useState<number>(1)
      const [isLoading, setIsLoading] = useState<boolean>(false)
      const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
    
      useEffect(() => {
        setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg')
      }, [])
    
      // 重置变换参数,重新绘制图片
      useEffect(() => {
        setIsLoading(true)
        translatePointXRef.current = 0
        translatePointYRef.current = 0
        fillStartPointXRef.current = 0
        fillStartPointYRef.current = 0
        setCanvasScale(1)
        fillImage()
      }, [fillImageSrc])
    
      // 画布参数变动时,重新监听canvas
      useEffect(() => {
        handleCanvas()
      }, [mouseMode, canvasScale, canvasCurrentHistory])
    
      // 监听画笔颜色变化
      useEffect(() => {
        const { current: canvas } = canvasRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!context) return
    
        context.strokeStyle = lineColor
        context.lineWidth = lineWidth
        context.lineJoin = 'round'
        context.lineCap = 'round'
      }, [lineWidth, lineColor])
    
      //监听缩放画布
      useEffect(() => {
        const { current: canvas } = canvasRef
        const { current: translatePointX } = translatePointXRef
        const { current: translatePointY } = translatePointYRef
        canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
      }, [canvasScale])
    
      useEffect(() => {
        const { current: canvas } = canvasRef
        const { current: canvasHistroyList } = canvasHistroyListRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!canvas || !context || canvasCurrentHistory === 0) return
        context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
      }, [canvasCurrentHistory])
    
      const fillImage = async () => {
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        const img: HTMLImageElement = new Image()
    
        if (!canvas || !wrap || !context) return
    
        img.src = await getURLBase64(fillImageSrc)
        img.onload = () => {
          // 取中间渲染图片
          // const centerX: number = canvas && canvas.width / 2 - img.width / 2 || 0
          // const centerY: number = canvas && canvas.height / 2 - img.height / 2 || 0
          canvas.width = img.width
          canvas.height = img.height
    
          // 背景设置为图片,橡皮擦的效果才能出来
          canvas.style.background = `url(${img.src})`
          context.drawImage(img, 0, 0)
          context.strokeStyle = lineColor
          context.lineWidth = lineWidth
          context.lineJoin = 'round'
          context.lineCap = 'round'
    
          // 设置变化基点,为画布容器中央
          canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
          // 清除上一次变化的效果
          canvas.style.transform = ''
          const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
          canvasHistroyListRef.current = []
          canvasHistroyListRef.current.push(imageData)
          // canvasCurrentHistoryRef.current = 1
          setCanvasCurrentHistory(1)
          setTimeout(() => { setIsLoading(false) }, 500)
        }
      }
    
      const generateLinePoint = (x: number, y: number) => {
        const { current: wrap } = wrapRef
        const { current: translatePointX } = translatePointXRef
        const { current: translatePointY } = translatePointYRef
        const wrapWidth: number = wrap?.offsetWidth || 0
        const wrapHeight: number = wrap?.offsetHeight || 0
        // 缩放位移坐标变化规律
        // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
        const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
        const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY
    
        return {
          pointX,
          pointY
        }
      }
    
      const handleLineMode = (downX: number, downY: number) => {
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!canvas || !wrap || !context) return
    
        const offsetLeft: number = canvas.offsetLeft
        const offsetTop: number = canvas.offsetTop
        // 减去画布偏移的距离(以画布为基准进行计算坐标)
        downX = downX - offsetLeft
        downY = downY - offsetTop
    
        const { pointX, pointY } = generateLinePoint(downX, downY)
        context.globalCompositeOperation = "source-over"
        context.beginPath()
        context.moveTo(pointX, pointY)
    
        canvas.onmousemove = null
        canvas.onmousemove = (event: MouseEvent) => {
          const moveX: number = event.pageX - offsetLeft
          const moveY: number = event.pageY - offsetTop
          const { pointX, pointY } = generateLinePoint(moveX, moveY)
          context.lineTo(pointX, pointY)
          context.stroke()
        }
        canvas.onmouseup = () => {
          const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
    
          // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态
          if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
            canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
          }
          canvasHistroyListRef.current.push(imageData)
          setCanvasCurrentHistory(canvasCurrentHistory + 1)
          context.closePath()
          canvas.onmousemove = null
          canvas.onmouseup = null
        }
      }
    
      const handleMoveMode = (downX: number, downY: number) => {
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
        const { current: fillStartPointX } = fillStartPointXRef
        const { current: fillStartPointY } = fillStartPointYRef
        if (!canvas || !wrap || mouseMode !== 0) return
    
        // 为容器添加移动事件,可以在空白处移动图片
        wrap.onmousemove = (event: MouseEvent) => {
          const moveX: number = event.pageX
          const moveY: number = event.pageY
    
          translatePointXRef.current = fillStartPointX + (moveX - downX)
          translatePointYRef.current = fillStartPointY + (moveY - downY)
    
          canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
        }
    
        wrap.onmouseup = (event: MouseEvent) => {
          const upX: number = event.pageX
          const upY: number = event.pageY
    
          wrap.onmousemove = null
          wrap.onmouseup = null;
    
          fillStartPointXRef.current = fillStartPointX + (upX - downX)
          fillStartPointYRef.current = fillStartPointY + (upY - downY)
        }
      }
    
      // 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色
      const handleEraserMode = (downX: number, downY: number) => {
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!canvas || !wrap || !context) return
    
        const offsetLeft: number = canvas.offsetLeft
        const offsetTop: number = canvas.offsetTop
        downX = downX - offsetLeft
        downY = downY - offsetTop
    
        const { pointX, pointY } = generateLinePoint(downX, downY)
    
        context.beginPath()
        context.moveTo(pointX, pointY)
    
        canvas.onmousemove = null
        canvas.onmousemove = (event: MouseEvent) => {
          const moveX: number = event.pageX - offsetLeft
          const moveY: number = event.pageY - offsetTop
          const { pointX, pointY } = generateLinePoint(moveX, moveY)
          context.globalCompositeOperation = "destination-out"
          context.lineWidth = lineWidth
          context.lineTo(pointX, pointY)
          context.stroke()
        }
        canvas.onmouseup = () => {
          const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
          if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
            canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
          }
          canvasHistroyListRef.current.push(imageData)
          setCanvasCurrentHistory(canvasCurrentHistory + 1)
          context.closePath()
          canvas.onmousemove = null
          canvas.onmouseup = null
        }
      }
    
      const handleCanvas = () => {
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!context || !wrap) return
    
        // 清除上一次设置的监听,以防获取参数错误
        wrap.onmousedown = null
        wrap.onmousedown = function (event: MouseEvent) {
          const downX: number = event.pageX
          const downY: number = event.pageY
    
          switch (mouseMode) {
            case MOVE_MODE:
              handleMoveMode(downX, downY)
              break
            case LINE_MODE:
              handleLineMode(downX, downY)
              break
            case ERASER_MODE:
              handleEraserMode(downX, downY)
              break
            default:
              break
          }
        }
    
        wrap.onwheel = null
        wrap.onwheel = (e: MouseWheelEvent) => {
          const { deltaY } = e
          const newScale: number = deltaY > 0
            ? (canvasScale * 10 - 0.1 * 10) / 10
            : (canvasScale * 10 + 0.1 * 10) / 10
          if (newScale < 0.1 || newScale > 2) return
          setCanvasScale(newScale)
        }
      }
    
      const handleScaleChange = (value: number) => {
        setCanvasScale(value)
      }
    
      const handleLineWidthChange = (value: number) => {
        setLineWidth(value)
      }
    
      const handleColorChange = (color: string) => {
        setLineColor(color)
      }
    
      const handleMouseModeChange = (event: RadioChangeEvent) => {
        const { target: { value } } = event
        const { current: canvas } = canvasRef
        const { current: wrap } = wrapRef
    
        setmouseMode(value)
    
        if (!canvas || !wrap) return
        switch (value) {
          case MOVE_MODE:
            canvas.style.cursor = 'move'
            wrap.style.cursor = 'move'
            break
          case LINE_MODE:
            canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
            wrap.style.cursor = 'default'
            break
          case ERASER_MODE:
            message.warning('橡皮擦功能尚未完善,保存图片会出现错误')
            canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
            wrap.style.cursor = 'default'
            break
          default:
            canvas.style.cursor = 'default'
            wrap.style.cursor = 'default'
            break
        }
      }
    
      const handleSaveClick = () => {
        const { current: canvas } = canvasRef
        // 可存入数据库或是直接生成图片
        console.log(canvas?.toDataURL())
      }
    
      const handlePaperChange = (value: string) => {
        const fillImageList = {
          'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
          'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
          'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
        }
        setFillImageSrc(fillImageList[value])
      }
    
      const handleRollBack = () => {
        const isFirstHistory: boolean = canvasCurrentHistory === 1
        if (isFirstHistory) return
        setCanvasCurrentHistory(canvasCurrentHistory - 1)
      }
    
      const handleRollForward = () => {
        const { current: canvasHistroyList } = canvasHistroyListRef
        const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
        if (isLastHistory) return
        setCanvasCurrentHistory(canvasCurrentHistory + 1)
      }
    
      const handleClearCanvasClick = () => {
        const { current: canvas } = canvasRef
        const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
        if (!canvas || !context || canvasCurrentHistory === 0) return
    
        // 清空画布历史
        canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
        setCanvasCurrentHistory(1)
    
        message.success('画布清除成功!')
      }
    
      return (
        <div>
          <CustomBreadcrumb list={['内容管理', '批阅作业']} />
          <div className="mark-paper__container" ref={containerRef}>
            <div className="mark-paper__wrap" ref={wrapRef}>
              <div
                className="mark-paper__mask"
                style={{ display: isLoading ? 'flex' : 'none' }}
              >
                <Spin
                  tip="图片加载中..."
                  indicator={<Icon type="loading" style={{ fontSize: 36 }} spin
                  />}
                />
              </div>
              <canvas
                ref={canvasRef}
                className="mark-paper__canvas">
                <p>很可惜,这个东东与您的电脑不搭!</p>
              </canvas>
            </div>
            <div className="mark-paper__sider">
              <div>
                选择作业:
                <Select
                  defaultValue="xueshengjia"
                  style={{
                    width: '100%', margin: '10px 0 20px 0'
                  }}
                  onChange={handlePaperChange} >
                  <OptGroup label="17软件一班">
                    <Option value="xueshengjia">学生甲</Option>
                    <Option value="xueshengyi">学生乙</Option>
                  </OptGroup>
                  <OptGroup label="17软件二班">
                    <Option value="xueshengbing">学生丙</Option>
                  </OptGroup>
                </Select>
              </div>
              <div>
                画布操作:<br />
                <div className="mark-paper__action">
                  <Tooltip title="撤销">
                    <i
                      className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`}
                      onClick={handleRollBack} />
                  </Tooltip>
                  <Tooltip title="恢复">
                    <i
                      className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`}
                      onClick={handleRollForward} />
                  </Tooltip>
                  <Popconfirm
                    title="确定清空画布吗?"
                    onConfirm={handleClearCanvasClick}
                    okText="确定"
                    cancelText="取消"
                  >
                    <Tooltip title="清空">
                      <i className="icon iconfont icon-qingchu" />
                    </Tooltip>
                  </Popconfirm>
                </div>
              </div>
              <div>
                画布缩放:
                <Tooltip placement="top" title='可用鼠标滚轮进行缩放'>
                  <Icon type="question-circle" />
                </Tooltip>
                <Slider
                  min={0.1}
                  max={2.01}
                  step={0.1}
                  value={canvasScale}
                  tipFormatter={(value) => `${(value).toFixed(2)}x`}
                  onChange={handleScaleChange} />
              </div>
              <div>
                画笔大小:
                <Slider
                  min={1}
                  max={9}
                  value={lineWidth}
                  tipFormatter={(value) => `${value}px`}
                  onChange={handleLineWidthChange} />
              </div>
              <div>
                模式选择:
                <Radio.Group
                  className="radio-group"
                  onChange={handleMouseModeChange}
                  value={mouseMode}>
                  <Radio value={0}>移动</Radio>
                  <Radio value={1}>画笔</Radio>
                  <Radio value={2}>橡皮擦</Radio>
                </Radio.Group>
              </div>
              <div>
                颜色选择:
                <div className="color-picker__container">
                  {['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => {
                    return (
                      <Tooltip placement="top" title={color} key={color}>
                        <div
                          role="button"
                          className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`}
                          style={{ background: color }}
                          onClick={() => handleColorChange(color)}
                        />
                      </Tooltip>
                    )
                  })}
                </div>
              </div>
              <Button onClick={handleSaveClick}>保存图片</Button>
            </div>
          </div>
        </div >
      )
    }
    
    export default MarkPaper as ComponentType
    

    总结

    到此这篇关于Html5 Canvas实现图片标记、缩放、移动和保存历史状态 (附转换公式)的文章就介绍到这了,更多相关Canvas 图片标记 缩放 移动内容请搜索脚本之家以前的文章或继续浏览下面的相关文章,希望大家以后多多支持脚本之家!

    上一篇:Html+Css+Jquery实现左侧滑动拉伸导航菜单栏的示例代码
    下一篇:HTML5 客户端数据库简易使用:IndexedDB
  • 相关文章
  • 

    © 2016-2020 巨人网络通讯

    时间:9:00-21:00 (节假日不休)

    地址:江苏信息产业基地11号楼四层

    《增值电信业务经营许可证》 苏B2-20120278

    Html5 Canvas实现图片标记、缩放、移动和保存历史状态功能 (附转换公式) Html5,Canvas,实现,图片,标记,