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
- Basic single-color
- fluorescent
- multi-color
- sprayers
- crayons
- bubbles
Basic Single Color
There are two key points to consider when implementing the basic functionality of the brush:
- 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.
- 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.
/**
* 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
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
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.
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.
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
- Record the radius and opacity of the bubble on mouseover.
- When rendering, use
context.arc
for circle drawing to render the bubble effect.
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 👻