DEV Community

Cover image for "Why nobody in using {insert your favourite feature} yet ?" Drawing on a Canvas - part 1
Alessandro Cipolletti
Alessandro Cipolletti

Posted on

"Why nobody in using {insert your favourite feature} yet ?" Drawing on a Canvas - part 1

"Why nobody has already used this to do that?"

Hi everybody,

I bet I'm not the only one who has this type of thought. I've been in the web dev community for almost 12 years now, and I found myself thinking about this several times.

Disclaimer: my written English sucks, I'm trying to improve, please be gentle😇

Every time a new major web feature appears, like Canvas or WebAssembly, it's inevitable for me to start immagine all sort of new applications that are now possible. Then (almost) none of them come to life. Perhaps they are just less cool than how they seem to me, but I had nothing to lose and I developed one of them.

If I had to pick one web feature as my favourite, Canvas would win hands down.

IMHO it's the most underrated feature we have right now, and with this series of articles I want to share what I came up with until now, hoping more people will see canvas' potential as I do. 

Because to me the real potential in Canvas isn't what we as developers can show to the users through it, but what users can give us back by using it. A Canvas on the web in the mobile era could really be the ultimate input for everything that's not a simple text, number or boolean.

My goal is to reach the most realistic drawing experience possible.

Spoiler:

Let's start from scratch. Canvas, we all know the basics:

// canvas creation
const myCanvas = document.createElement('canvas')
const ctx = myCanvas.getContext('2d')
myCanvas.width = 400
myCanvas.height = 150
container.appendChild(myCanvas)

// rect
ctx.fillStyle = 'rgb(255, 0, 0)'
ctx.fillRect(10, 10, 100, 80)

// circle
ctx.beginPath()
ctx.fillStyle = 'rgb(0, 0, 255)'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.arc(200, 50, 40, 0, 2 * Math.PI, true)
ctx.fill()

// image
ctx.drawImage(myImg, 280, 10, 80, 80)
Enter fullscreen mode Exit fullscreen mode

Alt Text

We'll need these basics a lot, so why not encapsulate them (with a few more options):

const drawSquare = (destinationContext, x, y, alpha, size, color, rotation = 0) => {
  const halfSize = size / 2
  destinationContext.globalAlpha = alpha
  destinationContext.fillStyle = color
  if (rotation % 90) {
    destinationContext.translate(x, y)
    destinationContext.rotate(rotation)
    destinationContext.fillRect(-halfSize, -halfSize, size, size)
    destinationContext.rotate(-rotation)
    destinationContext.translate(-x, -y)
  } else {
    destinationContext.fillRect(x - halfSize, y - halfSize, size, size)
  }
}

const drawCircle = (destinationContext, x, y, alpha, size, color) => {
  destinationContext.beginPath()
  destinationContext.fillStyle = color
  destinationContext.globalAlpha = alpha
  destinationContext.lineJoin = 'round'
  destinationContext.lineCap = 'round'
  destinationContext.arc(x, y, size / 2, 0, 2 * Math.PI, true)
  destinationContext.fill()
}

const drawImage = (destinationContext, x, y, alpha, size, image, rotation = 0) => {
  const halfSize = size / 2
  destinationContext.globalAlpha = alpha
  if (rotation % 360) {
    destinationContext.translate(x, y)
    destinationContext.rotate(rotation)
    destinationContext.drawImage(image, -halfSize, -halfSize, size, size)
    destinationContext.rotate(-rotation)
    destinationContext.translate(-x, -y)
  } else {
    destinationContext.drawImage(image, Math.round(x - halfSize), Math.round(y - halfSize), size, size)
  }
}
Enter fullscreen mode Exit fullscreen mode

And then use it:

drawSquare(ctx, 50, 150, 0.5, 80, 'rgb(255, 0, 0)', 30)
drawSquare(ctx, 110, 150, 0.7, 80, 'rgb(0, 255, 255)', -40)

drawCircle(ctx, 200, 150, 0.9, 50, 'rgb(255, 0, 0)')
drawCircle(ctx, 240, 150, 0.9, 60, 'rgb(255, 255, 0)')
drawCircle(ctx, 270, 150, 0.9, 70, 'rgb(0, 255, 255)')

drawImage(ctx, 350, 150, 0.6, 60, myImg, 45)
Enter fullscreen mode Exit fullscreen mode

Alt Text

Note that I gave almost the same signature to all of them. This will be very useful later on.

So now we're ready to draw something on the screen on touch input (yeah I'm using touch input in this example, it would be almost the same with mouse down/move).

const defaultToolSize = 20
const currentToolColor = 'rgb(255, 0, 0)'

const handleTouch = (e) => {
  const x = e.touches[0].clientX - myCanvas.offsetLeft
  const y = e.touches[0].clientY - myCanvas.offsetTop
  const alpha = e.touches[0].force || 1
  drawCircle(ctx, x, y, alpha, defaultToolSize, currentToolColor)
}

myCanvas.addEventListener('touchstart', handleTouch)
myCanvas.addEventListener('touchmove', handleTouch)
Enter fullscreen mode Exit fullscreen mode

Alt Text

Ok it's something. We can already see the skeleton of what will become ours line.

What if we would adapt the size based on the force pressure?

const defaultToolSize = 20
const sizeForceFactor = 2

const handleTouch = (e) => {
  const x = e.touches[0].clientX - myCanvas.offsetLeft
  const y = e.touches[0].clientY - myCanvas.offsetTop
  const force = e.touches[0].force || 1
  const size = defaultToolSize + (defaultToolSize * force)
  drawCircle(ctx, x, y, force size, currentToolColor)
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

And why not to adapt the size based on the touch move speed?

const sizeSpeedFactor = 5
const speedFactorLengthUnit = 200
let lastTouch = {
  x: -1,
  y: -1,
  force: 0,
}

// a bit of math
const round = (n, d = 0) => {
  const m = d ? Math.pow(10, d) : 1
  return Math.round(n * m) / m
}
const getDistanceBetweenTwoPoints = (x1, y1, x2, y2, decimals = 0) => 
  round(Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)), decimals)

const handleTouch = (e) => {
  const x = e.touches[0].clientX - myCanvas.offsetLeft
  const y = e.touches[0].clientY - myCanvas.offsetTop
  const force = e.touches[0].force || 1
  const distance = lastTouch.x >= 0 ? getDistanceBetweenTwoPoints(lastTouch.x, lastTouch.y, x, y) : 0
  const size = defaultToolSize +
    (defaultToolSize * force) +
    (defaultToolSize * sizeSpeedFactor * Math.min(distance / speedFactorLengthUnit, 1))

  drawCircle(ctx, x, y, force, size, currentToolColor)
  lastTouch = { x, y, force }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

We could go further and use stylus inclination too to adapt size and alpha, but it's enough for now. I will treat those another time.

Starting from these simple point we will create a realistic line.

That's all for now, I really hope this topic could interest some of you. I spent a lot of time chasing the goal of reproducing a paper-like experience on the web, and I hope this could be useful somehow in the future.

Please leave a comment if you like the topic. I'd be happy to respond.

Thanks for your time! See you in the next post

Oldest comments (0)