画板探秘系列:创意画笔第二期

前言

我目前在维护一款功能强大的开源创意画板。这个画板集成了多种创意画笔,可以让用户体验到全新的绘画效果。无论是在移动端还是PC端,都能享受到较好的交互体验和效果展示。并且此项目拥有许多强大的辅助绘画功能,包括但不限于前进后退、复制删除、上传下载、多画板和多图层等等。详细功能我就不一一罗列了,期待你的探索。

Link: https://songlh.top/paint-board/

Github: https://github.com/LHRUN/paint-board 欢迎Star⭐️

在项目的逐渐迭代中,我计划撰写一些文章,一方面是为了记录技术细节,这是我一直以来的习惯。另一方面则是为了推广一下,期望得到你的使用和反馈,当然如果能点个 Star 就是对我最大的支持。

我准备分3篇文章讲解创意画笔的实现, 本篇文章是第二篇, 所有的实现源码我都会上传到我的 Github 上.

实现源码Demo

多色画笔

  • 多色画笔效果如下

  • 多色画笔的实现类似于素材画笔, 都是通过 strokeStyle 接收一个 CanvasPattern 对象
  • 我们可以新建一个 canvas, 然后对这个 canvas 进行你想要的效果进行绘制, 最后通过这个 canvas 创建一个 pattern 赋值到 strokeStyle 就可以出现多色画笔的效果
import { useEffect, useRef, useState, MouseEvent } from 'react'
import './index.css'

let isMouseDown = false
let movePoint: { x: number, y: number } | null = null
const COLOR_WIDTH = 5 // 每个颜色的宽度

/**
 * 获取多色画笔 pattern
 * @param colors 颜色数组, 需要绘制的颜色
 */
const getPattern = async (colors: string[]) => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d') as CanvasRenderingContext2D
  renderRow(canvas, context, colors)
  return context.createPattern(canvas, 'repeat')
}

/**
 * 横排效果绘制
 */
const renderRow = (
  canvas: HTMLCanvasElement,
  context: CanvasRenderingContext2D,
  colors: string[]
) => {
  canvas.width = 20
  canvas.height = colors.length * COLOR_WIDTH
  colors.forEach((color, i) => {
    context.fillStyle = color
    context.fillRect(0, COLOR_WIDTH * i, 20, COLOR_WIDTH)
  })
}

function PaintBoard() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
  const [context2D, setContext2D] = useState<CanvasRenderingContext2D | null>(null)

  useEffect(() => {
    initDraw()
  }, [canvasRef])

  const initDraw = async () => {
    if (canvasRef?.current) {
      const context2D = canvasRef?.current.getContext('2d')
      if (context2D) {
        context2D.lineCap = 'round'
        context2D.lineJoin = 'round'
        context2D.lineWidth = 10
        // 根据生成的 pattern 赋值到 strokeStyle
        const pattern = await getPattern(['blue', 'red', 'black'])
        if (pattern) {
          context2D.strokeStyle = pattern
        }
        
        setContext2D(context2D)
      }
    }
  }

  const onMouseDown = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = true
  }

  const onMouseMove = (event: MouseEvent) => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    if (isMouseDown) {
      const { clientX, clientY } = event
      if (movePoint) {
        context2D.beginPath()
        context2D.moveTo(movePoint.x, movePoint.y)
        context2D.lineTo(clientX, clientY)
        context2D.stroke()
      }
      movePoint = {
        x: clientX,
        y: clientY
      }
    }
  }

  const onMouseUp = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = false
    movePoint = null
  }

  return (
    <div>
      <canvas
        ref={canvasRef}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
      />
    </div>
  )
}

文字画笔

  • 文字画笔是会跟着鼠标移动进行文字绘制, 效果如下

  • 文字画笔绘制拆解分为三步,
      1. 首先根据两次移动的坐标得出位移距离, 然后通过 measureText 得出文字的宽度, 如果距离大于文字宽度说明是可以绘制的
      1. 然后通过两个点的向量, 根据 Math.atan2 得出角度, 然后根据这个角度绘制当前的某个文字
      1. 最后更新轨迹坐标, 同时绘制文字坐标调整为下一个, 如果绘制完就重新开始
interface Point {
  x: number
  y: number
}

let isMouseDown = false
let movePoint: Point = { x: 0, y: 0 }

let counter = 0 // 当前需要绘制文字的下标
const textValue = 'PaintBoard' // 绘制文字内容
const minFontSize = 5 // 文字绘制最小限制

/**
 * 根据勾股定理得出距离
 */
const getDistance = (start: Point, end: Point) => {
  return Math.sqrt(Math.pow(start.x - end.x, 2) + Math.pow(start.y - end.y, 2))
}

function PaintBoard() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
  const [context2D, setContext2D] = useState<CanvasRenderingContext2D | null>(null)

  useEffect(() => {
    if (canvasRef?.current) {
      const context2D = canvasRef?.current.getContext('2d')
      if (context2D) {
        context2D.fillStyle = '#000'
        setContext2D(context2D)
      }
    }
  }, [canvasRef])

  const onMouseDown = (event: MouseEvent) => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = true
    const { clientX, clientY } = event
    movePoint = {
      x: clientX,
      y: clientY
    }
  }

  const onMouseMove = (event: MouseEvent) => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    if (isMouseDown) {
      const { clientX, clientY } = event

      // 得出两点距离
      const distance = getDistance(movePoint, { x: clientX, y: clientY })
      const fontSize = minFontSize + distance
      const letter = textValue[counter]
      context2D.font = `${fontSize}px Georgia`

      // 得出文字宽度
      const textWidth = context2D.measureText(letter).width

      if (distance > textWidth) {
        // 计算当前移动角度
        const angle = Math.atan2(clientY - movePoint.y, clientX - movePoint.x)

        context2D.save();
        context2D.translate(movePoint.x, movePoint.y)
        context2D.rotate(angle);
        context2D.fillText(letter, 0, 0);
        context2D.restore();

        // 更新文字绘制后位置
        movePoint = {
          x: movePoint.x + Math.cos(angle) * textWidth,
          y: movePoint.y + Math.sin(angle) * textWidth
        }

        // 更新所绘文字下标
        counter++
        if (counter > textValue.length - 1) {
          counter = 0
        }
      }
    }
  }

  const onMouseUp = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = false
    movePoint = { x: 0, y: 0 }
  }

  return (
    <div>
      <canvas
        ref={canvasRef}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
      />
    </div>
  )
}

多线连接

  • 多线连接效果如下:

  • 多线连接是在正常的绘制连接中, 会对以前的轨迹点进行二次连接, 然后通过调整需要连接的点间隔或者连接数量, 可以达到不同的效果
interface Point {
  x: number
  y: number
}

let isMouseDown = false
let movePoints: Point[] = [] // 鼠标移动轨迹点记录

function PaintBoard() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
  const [context2D, setContext2D] = useState<CanvasRenderingContext2D | null>(null)

  useEffect(() => {
    if (canvasRef?.current) {
      const context2D = canvasRef?.current.getContext('2d')
      if (context2D) {
        context2D.lineCap = 'round'
        context2D.lineJoin = 'round'
        context2D.lineWidth = 3
        
        setContext2D(context2D)
      }
    }
  }, [canvasRef])

  const onMouseDown = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = true
  }

  const onMouseMove = (event: MouseEvent) => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    if (isMouseDown) {
      const { clientX, clientY } = event
      const length = movePoints.length
      if (length) {
        // 正常线段连接
        context2D.beginPath()
        context2D.moveTo(movePoints[length - 1].x, movePoints[length - 1].y)
        context2D.lineTo(clientX, clientY)
        context2D.stroke()

        /**
         * 对以往的轨迹点进行连接
         * 目前是每间隔5个点进行连接, 连接数量为3
         */
        if (length % 5 === 0) {
          for (
            let i = movePoints.length - 5, count = 0;
            i >= 0 && count < 3;
            i = i - 5, count++
          ) {
            context2D.save()
            context2D.beginPath()
            context2D.lineWidth = 1
            context2D.moveTo(movePoints[length - 1].x, movePoints[length - 1].y)
            context2D.lineTo(movePoints[i].x, movePoints[i].y)
            context2D.stroke()
            context2D.restore()
          }
        }
      }
      movePoints.push({
        x: clientX,
        y: clientY
      })
    }
  }

  const onMouseUp = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = false
    movePoints = []
  }

  return (
    <div>
      <canvas
        ref={canvasRef}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
      />
    </div>
  )
}

网状画笔

  • 网状画笔效果如下

  • 网状画笔是在正常的绘制过程中, 会对以往的轨迹点进行遍历, 如果满足一定条件就会判断视为相近, 然后对相近的点进行二次连接, 多个连接线就会达到网状的效果
interface Point {
  x: number
  y: number
}

let isMouseDown = false
let movePoints: Point[] = [] // 鼠标轨迹点记录

function PaintBoard() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
  const [context2D, setContext2D] = useState<CanvasRenderingContext2D | null>(null)

  useEffect(() => {
    if (canvasRef?.current) {
      const context2D = canvasRef?.current.getContext('2d')
      if (context2D) {
        context2D.lineCap = 'round'
        context2D.lineJoin = 'round'
        context2D.lineWidth = 3
        
        setContext2D(context2D)
      }
    }
  }, [canvasRef])

  const onMouseDown = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = true
  }

  const onMouseMove = (event: MouseEvent) => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    if (isMouseDown) {
      const { clientX, clientY } = event
      const length = movePoints.length
      if (length) {
        // 正常绘制连接
        context2D.beginPath()
        context2D.moveTo(movePoints[length - 1].x, movePoints[length - 1].y)
        context2D.lineTo(clientX, clientY)
        context2D.stroke()

        if (length % 2 === 0) {
          const limitDistance = 1000
          /**
           * 如果满足 dx * dx + dy * dy < 1000 就视为两点相近, 进行线段二次连接
           * 这个 limitDistance 可以自行调整
           */
          for (let i = 0; i < length; i++) {
            const dx = movePoints[i].x - movePoints[length - 1].x
            const dy = movePoints[i].y - movePoints[length - 1].y
            const d = dx * dx + dy * dy
        
            if (d < limitDistance) {
              context2D.save()
              context2D.beginPath()
              context2D.lineWidth = 1
              context2D.moveTo(movePoints[length - 1].x, movePoints[length - 1].y)
              context2D.lineTo(movePoints[i].x, movePoints[i].y)
              context2D.stroke()
              context2D.restore()
            }
          }
        }
      }
      movePoints.push({
        x: clientX,
        y: clientY
      })
    }
  }

  const onMouseUp = () => {
    if (!canvasRef?.current || !context2D) {
      return
    }
    isMouseDown = false
    movePoints = []
  }

  return (
    <div>
      <canvas
        ref={canvasRef}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={onMouseUp}
      />
    </div>
  )
}

总结

感谢你的阅读。以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏。如果有任何问题,欢迎在评论区进行讨论