Canvas Artistry:Drawing magic with multiple effects

Introduction

About half a year ago. I made a Canvas multifunctional drawing board with some of the following main features

  • Freestyle drawing
    • Supports color changes and real-time adjustment of line width according to the drawing speed.
    • Offers a multitude of effects such as fluorescent, multi-color, crayon, spray, bubbles and more.
  • Eraser.
    • Linear erase function with mouse movement.
  • Drawing text
    • By double clicking on the drawing board, the user can enter text and draw in the specified position.
  • Drawing board Drag & Drop
    • Holding down the space bar allows the user to drag and drop the drawing board infinitely.
  • Select Mode
    • Once in selection mode, the user can box in elements by clicking on them, zoom or move them by holding down the handle, and delete selected elements with the Backspace key.
  • Layer
    • The content of the drawing board is displayed in the order of the layers, and users can add, delete and sort layers.
  • Provides undo and reverse undo features that allow users to clear the board or save the content as an image.

Then recently I had some free time and decided to write a series of articles documenting my design ideas and code. This is my second post. If you are interested in this project, feel free to visit and also feel free to give the project a Star ⭐️.

In this article, I’ll detail the six drawing effects

  1. Basic single-color
  2. fluorescent
  3. multi-color
  4. sprayers
  5. crayons
  6. bubbles

cover

Basic Single Color

There are two key points to consider when implementing the basic functionality of the brush:

  1. Speed-aware line width adjustment: Dynamically calculates the current moving speed while the mouse is moving and adjusts the line width according to the speed. This design aims to narrow the line width when the mouse is moving fast and return to normal when it is moving slow to enhance the drawing effect.
  2. To avoid poor results when connecting points in a straight line, a Bezier curve is used for the connection. This curved connection helps to smooth the transition and enhance the beauty and accuracy of the drawn lines.

basic

/**
 * Add new positon on mouseover
 * @param position
 */
addPosition(position: MousePosition) {
  this.positions.push(position)
  if (this.positions.length > 1) {
    // Calculate movement speed
    const mouseSpeed = this._computedSpeed(
      this.positions[this.positions.length - 2],
      this.positions[this.positions.length - 1]
    )
    // // Calculate the current line width
    const lineWidth = this._computedLineWidth(mouseSpeed)
    this.lineWidths.push(lineWidth)
  }
}

/**
 * Calculate movement speed
 * @param start start position
 * @param end end position
 */
_computedSpeed(start: MousePosition, end: MousePosition) {
  // get distance
  const moveDistance = getDistance(start, end)

  const curTime = Date.now()
  // Get move interval
  const moveTime = curTime - this.lastMoveTime
  // calculation speed
  const mouseSpeed = moveDistance / moveTime
  // Update last move time
  this.lastMoveTime = curTime
  return mouseSpeed
}

/**
 * Calculate width
 * @param speed Mouse Move Speed
 */
_computedLineWidth(speed: number) {
  let lineWidth = 0
  const minWidth = this.minWidth
  const maxWidth = this.maxWidth
  if (speed >= this.maxSpeed) {
    lineWidth = minWidth
  } else if (speed <= this.minSpeed) {
    lineWidth = maxWidth
  } else {
    lineWidth = maxWidth - (speed / this.maxSpeed) * maxWidth
  }

  lineWidth = lineWidth * (1 / 3) + this.lastLineWidth * (2 / 3)
  this.lastLineWidth = lineWidth
  return lineWidth
}

Iterate over all coordinates when rendering

/**
 * free draw render
 * @param context canvas 2d render context
 * @param instance FreeDraw
 */
function freeDrawRender(
  context: CanvasRenderingContext2D,
  instance: FreeLine
) {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // Right now it's just the basic brushes, but we'll be adding different cases later.
    case FreeDrawStyle.Basic:
      context.strokeStyle = instance.colors[0]
      break
    default:
      break
  }
  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      case FreeDrawStyle.Basic:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * Drawing basic lines
 * @param instance FreeDraw instance
 * @param i current index
 * @param context canvas 2d render context
 * @param cb Some pre-draw processing, change some styles
 * 
 * 1. If it's the first two coordinates, just connect them via lineTo
 * 2. If the coordinates are after the first two, the connection is made using a Bezier curve
 *    For example, if there are three points a, b, and c, when we get to point c, we connect the middle point of the ab coordinate as the start point, the middle point of the bc coordinate as the end point, and point b as the control point.
 */
function _drawBasic(
  instance: FreeLine,
  i: number,
  context: CanvasRenderingContext2D
  cb?: (
    instance: FreeDraw,
    i: number,
    context: CanvasRenderingContext2D
  ) => void
) {
  const { positions, lineWidths } = instance
  const { x: centerX, y: centerY } = positions[i - 1]
  const { x: endX, y: endY } = positions[i]
  context.beginPath()
  if (i == 1) {
    context.moveTo(centerX, centerY)
    context.lineTo(endX, endY)
  } else {
    const { x: startX, y: startY } = positions[i - 2]
    const lastX = (startX + centerX) / 2
    const lastY = (startY + centerY) / 2
    const x = (centerX + endX) / 2
    const y = (centerY + endY) / 2
    context.moveTo(lastX, lastY)
    context.quadraticCurveTo(centerX, centerY, x, y)
  }

  context.lineWidth = lineWidths[i]
  cb?.(instance, i, context)
  context.stroke()
}

Fluorescent

Fluorescent simply adds a shadow to the base style

fluorescent

function freeDrawRender(
  context: CanvasRenderingContext2D,
  instance: FreeLine
) {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // Fluorescent  Adds a shadow effect
    case FreeDrawStyle.Shadow:
      context.shadowColor = instance.colors[0]
      context.strokeStyle = instance.colors[0]
      break
    default:
      break
  }
  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // Fluorescent
      case FreeDrawStyle.Shadow:
        _drawBasic(instance, i, context, (instance, i, context) => {
          context.shadowBlur = instance.lineWidths[i]
        })
        break
      default:
        break
    }
  }
  context.restore()
}

Multi Color

Multi-color brushes require the use of context.createPattern, an api that allows you to create a specified template through the canvas, which can then be made to repeat the meta-image in the specified direction, for more information on how to use this see MDN

multi

export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // MultiColor
    case FreeDrawStyle.MultiColor:
      context.strokeStyle = getMultiColorPattern(instance.colors)
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // MultiColor
      case FreeDrawStyle.MultiColor:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * Get multi-color templates
 * @param colors multicolor array
 */
const getMultiColorPattern = (colors: string[]) => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d') as CanvasRenderingContext2D
  const COLOR_WIDTH = 5 // Width of each color

  canvas.width = COLOR_WIDTH * colors.length
  canvas.height = 20
  colors.forEach((color, i) => {
    context.fillStyle = color
    context.fillRect(COLOR_WIDTH * i, 0, COLOR_WIDTH, 20)
  })
  return context.createPattern(canvas, 'repeat') as CanvasPattern
}

Spray

Spray is a snowflake-like effect that draws randomly on the path of mouse movement. Initially during the implementation, I found that if random snowflake points were recorded for each point and then cached down, it would lead to the problem of excessive memory usage. To solve this problem, I tried generating 5 different sets of data in advance and displaying them sequentially, which can effectively control the memory usage while achieving the random effect.

spray

export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // Spray
    case FreeDrawStyle.Spray:
      context.fillStyle = instance.colors[0]
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // Spray
      case FreeDrawStyle.Spray:
        _drawSpray(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * Spray
 * @param instance Spray instance
 * @param i current index
 * @param context canvas 2d
 */
const _drawSpray = (
  instance: FreeDraw,
  i: number,
  context: CanvasRenderingContext2D
) => {
  const { x, y } = instance.positions[i]
  for (let j = 0; j < 50; j++) {
    /**
     * sprayPoint is 5 sets of randomized spray data that I generated in advance.
     * {
     *    angle
     *    radius
     *    alpha
     * }
     */
    const { angle, radius, alpha } = sprayPoint[i % 5][j]
    context.globalAlpha = alpha
    const distanceX = radius * Math.cos(angle)
    const distanceY = radius * Math.sin(angle)
    // Limit the width of the spray according to the width, because the spray is too small to look good, I'll uniformly double the size of the
    if (
      distanceX < instance.lineWidths[i] * 2 &&
      distanceY < instance.lineWidths[i] * 2 &&
      distanceX > -instance.lineWidths[i] * 2 &&
      distanceY > -instance.lineWidths[i] * 2
    ) {
      context.fillRect(x + distanceX, y + distanceY, 2, 2)
    }
  }
}

Crayon

The crayon effect is also achieved using context.createPattern, first, with the current color as the base color, and then by overlaying a sheet of transparent crayon material pattern. This method is able to render the texture of the crayon by overlaying the material pattern while preserving the color of the brush.

crayon

export const freeDrawRender = (
  context: CanvasRenderingContext2D,
  instance: FreeDraw,
  material: Material
) => {
  context.save()
  context.lineCap = 'round'
  context.lineJoin = 'round'
  switch (instance.style) {
    // ...
    // Crayon
    case FreeDrawStyle.Crayon:
      context.strokeStyle = getCrayonPattern(
        instance.colors[0],
        material.crayon
      )
      break
    default:
      break
  }

  for (let i = 1; i < instance.positions.length; i++) {
    switch (instance.style) {
      // ...
      // Crayon
      case FreeDrawStyle.Crayon:
        _drawBasic(instance, i, context)
        break
      default:
        break
    }
  }
  context.restore()
}

/**
 * get crayon template
 * @param color crayon color
 * @param crayon crayon material
 */
const getCrayonPattern = (color: string, crayon: Material['crayon']) => {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d') as CanvasRenderingContext2D
  canvas.width = 100
  canvas.height = 100
  context.fillStyle = color
  context.fillRect(0, 0, 100, 100)
  if (crayon) {
    context.drawImage(crayon, 0, 0, 100, 100)
  }
  return context.createPattern(canvas, 'repeat') as CanvasPattern
}

Bubble

  1. Record the radius and opacity of the bubble on mouseover.
  2. When rendering, use context.arc for circle drawing to render the bubble effect.

bubble

addPosition(position: MousePosition) {
  // ...
  // Record the radius and opacity of the bubble
  if (this.style === FreeDrawStyle.Bubble && this.bubbles) {
    this.bubbles.push({
      // getRandomInt Get random integer in range
      radius: getRandomInt(this.minWidth * 2, this.maxWidth * 2),
      // opacity
      opacity: Math.random()
    })
  }
  // ...
}

/**
 * draw bubble
 * @param instance bubble instance
 * @param i current index
 * @param context canvas 2D
 */
const _drawBubble = (
  instance: FreeDraw,
  i: number,
  context: CanvasRenderingContext2D
) => {
  context.beginPath()
  if (instance.bubbles) {
    const { x, y } = instance.positions[i]
    context.globalAlpha = instance.bubbles[i].opacity
    context.arc(x, y, instance.bubbles[i].radius, 0, Math.PI * 2, false)
    context.fill()
  }
}

Conclusion

If you’re also writing related features, I hope this will help you, and if you find any problems or have a good solution, please feel free to discuss 👻