效果预览

170ebaf80d5910e9_tplv-t2oaga2asx-zoom-in-crop-mark_1304_0_0_0.gif

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

采用的是canvas绘制画笔,由 css3 的transform属性来进行平移与缩放,因为呢考虑到如果用 canvas 的drawImage或者scale等属性进行变化,生成出来的图片也会有影响,想着直接 css3 变化,canvas 用来做画笔等功能。大佬们有何妙招,在评论区指点指点!

公式推导

坐标转换公式

转换公式介绍

举一下横坐标的例子,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要

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

推导过程

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

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

    Untitled

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

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

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

    得到如下结果

    Untitled

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

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

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

    Untitled

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

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

    Untitled

    好了,我们差不多已经可以拿到坐标之间的关系了,我们可以列一个表

    Untitled

    还觉得不放心?我们可以换一下,缩放倍数与容器宽高等进行计算

    Untitled

    不知道大家有没有感觉呢,然后我们就可以慢慢根据坐标推出通用的公式啦

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

    当然,我们或许还有这个translateX没有尝试,这个就比较简单一点了,脑内模拟一下,就知道我们可以减去位移的距离就 ok 啦。我们测试一下

    我们先修改一下样式,新增一下位移的距离

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

    Untitled

    还是我们上面的状态,ok,我们现在蓝色跟绿色的标记还是一一对应的,那我们看看现在的坐标情况

    我们分别运用公式算一下出来的坐标是怎么样的 (以下为经过坐标换算)

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

    测试公式

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

    我们直接沿用上面的 demo,测试一下如果元素进行了变化,我们鼠标点下的地方生成一个标记,位置是否显示正确

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

    Untitled

    可能有人会问,为什么要减去这个offsetLeftoffsetTop呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。

组件设计

既然 demo 啥的都已经测试了 ok 了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)

1. 基本的画布构成

Untitled

我们先简单分析一下这个构成吧,其实主要就是一个画布的容器,右边一个工具栏,仅此而已

Untitled

大体就这样子啦!

<div class ref={wrapRef}>
  <canvas
    ref={canvasRef}
    class>
    <p>很可惜,这个东东与您的电脑不搭!</p>
  </canvas>
  <div class />
</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 画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作

Untitled

简单的总结一下:

// 定义一些变量,来保存当前/最新的移动状态
// 当前位移的距离
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这个属性来模拟橡皮擦的实现,不过,这时候图片生成出来之后,橡皮擦的痕迹会变成白色,而不是透明

此步骤与画笔实现差不多,只有一点点小变动

// 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色
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)
  }
}

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

总结一下: