Skip to content

Pad

Overview

The VBsbPad component is a versatile drawing pad that allows users to create resizable sketches and drawings on a canvas. This component supports a variety of customizable options, such as different stroke types, eraser functionality, line styles, background colors, and more. It is built with Vue 3, using the Composition API, and integrates with Vuetify for a polished UI.

The VBsbPad component can be used in applications that require user input drawing capabilities, such as graphic design tools, signature pads, or collaborative whiteboard features.

Usage Examples

Basic Usage

vue
<template>
  <VBsbPad />
</template>

<script setup>
import VBsbPad from '@/components/VBsbPad.vue'
</script>

Advanced Usage with Custom Props

vue
<template>
  <VBsbPad
    :strokeType="'circle'"
    :color="'#ff6347'"
    :lineWidth="8"
    :fillShape="true"
    :backgroundColor="'#fafafa'"
    :eraser="false"
    :lock="false"
    :outputWidth="600"
    :outputHeight="400"
  />
</template>

<script setup>
import VBsbPad from '@/components/VBsbPad.vue'
</script>

Handling Events

vue
<template>
  <VBsbPad @update:image="handleImageUpdate" />
</template>

<script setup>
import VBsbPad from '@/components/VBsbPad.vue'

const handleImageUpdate = (imageDataUrl) => {
  console.log('Updated Image Data:', imageDataUrl)
}
</script>

API

Props

PropTypeDefaultDescription
strokeTypeString'dash'Type of stroke to draw (e.g., 'dash', 'line', 'square', 'circle', 'triangle', 'half_triangle').
fillShapeBooleanfalseWhether to fill the drawn shape with the selected color.
imageString''URL of the image to load onto the canvas.
eraserBooleanfalseToggles eraser functionality to remove parts of the drawing.
colorString'#000000'The color of the stroke.
lineWidthNumber5The width of the drawn line.
lineCapString'round'The style of line ending (e.g., 'round', 'square', 'butt').
lineJoinString'miter'The type of corner created when two lines meet ('miter', 'round', 'bevel').
lockBooleanfalseLocks the canvas to prevent drawing or erasing.
stylesObject{}Additional styles to apply to the canvas.
classes[Array, String, Object]nullCSS classes to apply to the canvas element.
backgroundColorString'#FFFFFF'The background color of the canvas.
backgroundImageStringnullURL of a background image for the canvas.
saveAsString'png'The format for saving the drawing ('jpeg' or 'png').
canvasIdString'canvas-' + uniqueIdUnique ID for the canvas element.
initialImageArray[]Initial set of strokes to load into the canvas.
additionalImagesArray[]Additional images to be added on top of the canvas.
outputWidthNumbernullThe width of the output image.
outputHeightNumbernullThe height of the output image.

Emits

EventPayloadDescription
update:imageString (Data URL)Emitted when the canvas is saved, containing the image data in the specified format.

Exposes

MethodDescription
clear()Clears the canvas.
reset()Resets the canvas, removing all drawings and returning it to its initial state.
undo()Undoes the last drawing action.
redo()Redoes the last undone drawing action.
save()Saves the current canvas drawing and emits the update:image event.
startDraw()Starts a drawing action (triggered on interaction).
draw()Draws on the canvas (called continuously while dragging).
stopDraw()Stops the current drawing action.
handleResize()Handles resizing of the canvas to fit its container.

Types

Stroke Types

  • Dash: Dashed lines for decorative drawings.
  • Line: Simple straight lines.
  • Square: Creates rectangular shapes.
  • Circle: Draws circles with the given parameters.
  • Triangle: Draws triangular shapes.
  • Half Triangle: Creates a right-angle triangle.

Line Cap and Line Join Types

  • Line Cap: 'round', 'square', 'butt'.
  • Line Join: 'miter', 'round', 'bevel'.

Source

Component
vue
<template>
  <div ref="container" @touchmove.prevent>
    <canvas
      ref="canvas"
      :id="canvasId"
      :width="canvasWidth"
      :height="canvasHeight"
      :style="{ touchAction: 'none', ...(typeof styles === 'object' ? styles : {}) }"
      :class="classes"
      @mousedown="startDraw"
      @mousemove="draw"
      @mouseup="stopDraw"
      @mouseleave="stopDraw"
      @touchstart="startDraw"
      @touchmove="draw"
      @touchend="stopDraw"
      @touchleave="stopDraw"
      @touchcancel="stopDraw"
      @pointerdown="startDraw"
      @pointermove="draw"
      @pointerup="stopDraw"
      @pointerleave="stopDraw"
      @pointercancel="stopDraw"
    ></canvas>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, defineProps } from 'vue'

interface Coordinate {
  x: number
  y: number
}

interface Stroke {
  type: string
  from: Coordinate
  coordinates: Coordinate[]
  color: string
  width: number
  fill: boolean
  lineCap: CanvasLineCap
  lineJoin: CanvasLineJoin
}

interface Scale {
  x: number
  y: number
}

interface DataInit {
  loadedImage: HTMLImageElement | null
  drawing: boolean
  images: Stroke[]
  strokes: Stroke
  guides: Coordinate[]
  trash: Stroke[]
  scale: Scale
}

const props = defineProps({
  strokeType: {
    type: String,
    validator: (value: string): boolean =>
      ['dash', 'line', 'square', 'circle', 'triangle', 'half_triangle'].includes(value),
    default: 'dash',
  },
  fillShape: {
    type: Boolean,
    default: false,
  },
  image: {
    type: String,
    default: '',
  },
  eraser: {
    type: Boolean,
    default: false,
  },
  color: {
    type: String,
    default: '#000000',
  },
  lineWidth: {
    type: Number,
    default: 5,
  },
  lineCap: {
    type: String,
    validator: (value: string): boolean => ['round', 'square', 'butt'].includes(value),
    default: 'round',
  },
  lineJoin: {
    type: String,
    validator: (value: string): boolean => ['miter', 'round', 'bevel'].includes(value),
    default: 'miter',
  },
  lock: {
    type: Boolean,
    default: false,
  },
  styles: {
    type: Object,
    default: () => ({}),
  },
  classes: [Array, String, Object],
  backgroundColor: {
    type: String,
    default: '#FFFFFF',
  },
  backgroundImage: {
    type: String,
    default: null,
  },
  saveAs: {
    type: String,
    validator: (value: string) => ['jpeg', 'png'].includes(value),
    default: 'png',
  },
  canvasId: {
    type: String,
    default: 'canvas-' + Math.random().toString(36).substr(2, 9),
  },
  initialImage: {
    type: Array,
    default: () => [],
  },
  additionalImages: {
    type: Array,
    default: () => [],
  },
  outputWidth: {
    type: Number,
  },
  outputHeight: {
    type: Number,
  },
})

const emits = defineEmits(['update:image'])

const container = ref<HTMLElement | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
const context = ref<CanvasRenderingContext2D | null>(null)
const canvasWidth = ref(0)
const canvasHeight = ref(0)

const data = ref<DataInit>({
  loadedImage: null,
  drawing: false,
  images: [],
  strokes: {
    type: '',
    from: { x: 0, y: 0 },
    coordinates: [],
    color: '#000000',
    width: 0,
    fill: false,
    lineCap: 'round' as CanvasLineCap,
    lineJoin: 'miter' as CanvasLineJoin,
  },
  guides: [],
  trash: [],
  scale: {
    x: 1,
    y: 1,
  },
})

const redrawStrokes = () => {
  data.value.images.forEach((stroke: Stroke) => {
    if (context.value) {
      drawShape(
        context.value,
        stroke,
        stroke.type !== 'eraser' && stroke.type !== 'dash' && stroke.type !== 'line',
      )
    }
  })
}

const handleResize = async () => {
  if (!container.value) return
  const canvas = document.querySelector(`#${props.canvasId}`) as HTMLCanvasElement
  if (canvas) {
    const tempCanvas = document.createElement('canvas')
    const tempCtx = tempCanvas.getContext('2d')

    if (tempCtx) {
      tempCanvas.width = canvasWidth.value
      tempCanvas.height = canvasHeight.value
      tempCtx.drawImage(canvas, 0, 0)

      canvasWidth.value = container.value?.clientWidth || canvasWidth.value
      canvasHeight.value = container.value?.clientHeight || canvasHeight.value

      const scaleX = canvasWidth.value / tempCanvas.width
      const scaleY = canvasHeight.value / tempCanvas.height
      data.value.scale = { x: scaleX, y: scaleY }

      await nextTick()

      clear()
      if (context.value) {
        context.value.scale(scaleX, scaleY)
        context.value.drawImage(tempCanvas, 0, 0)
        redrawStrokes()
      }
    }
  }
}

watch(
  () => props.backgroundColor,
  () => {
    redraw(true)
  },
)

watch(
  () => props.backgroundImage,
  () => {
    data.value.loadedImage = null
    redraw(true)
  },
)

onMounted(async () => {
  canvasWidth.value = container.value?.clientWidth || canvasWidth.value
  canvasHeight.value = container.value?.clientHeight || canvasHeight.value
  if (canvas.value) context.value = canvas.value.getContext('2d')
  await setBackground()
  drawInitialImage()
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
})

const setBackground = async () => {
  clear()
  if (context.value) {
    context.value.fillStyle = props.backgroundColor
    context.value.fillRect(0, 0, Number(canvasWidth.value), Number(canvasHeight.value))
    await drawBackgroundImage()
    save()
  }
}

const drawInitialImage = () => {
  if (props.initialImage.length > 0) {
    data.value.images = data.value.images.concat(props.initialImage as Stroke[])
    redraw(true)
  }
}

const drawBackgroundImage = async () => {
  if (!data.value.loadedImage && props.backgroundImage) {
    return new Promise<void>((resolve) => {
      const image = new Image()
      image.src = props.backgroundImage as string
      if (context.value) {
        context.value.drawImage(image, 0, 0, Number(canvasWidth.value), Number(canvasHeight.value))
        data.value.loadedImage = image
        resolve()
      }
    })
  } else if (data.value.loadedImage) {
    if (context.value) {
      context.value.drawImage(
        data.value.loadedImage,
        0,
        0,
        Number(canvasWidth.value),
        Number(canvasHeight.value),
      )
    }
  }
}

const clear = () => {
  if (context.value) {
    context.value.clearRect(0, 0, Number(canvasWidth.value), Number(canvasHeight.value))
  }
}

const reset = () => {
  if (!props.lock) {
    data.value.images = []
    data.value.strokes = {
      type: '',
      from: { x: 0, y: 0 },
      coordinates: [],
      color: '',
      width: 0,
      fill: false,
      lineCap: 'round' as CanvasLineCap,
      lineJoin: 'miter' as CanvasLineJoin,
    }
    data.value.guides = []
    data.value.trash = []
    redraw(true)
  }
}

const undo = () => {
  if (!props.lock) {
    const strokes = data.value.images.pop()
    if (strokes) {
      data.value.trash.push(strokes)
      redraw(true)
    }
  }
}

const redo = () => {
  if (!props.lock) {
    const strokes = data.value.trash.pop()
    if (strokes) {
      data.value.images.push(strokes)
      redraw(true)
    }
  }
}

const getCoordinates = (event: MouseEvent | TouchEvent) => {
  let x, y
  const canvas = document.querySelector(`#${props.canvasId}`) as HTMLCanvasElement
  const rect = canvas.getBoundingClientRect()

  if ((event as TouchEvent).touches && (event as TouchEvent).touches.length > 0) {
    x = ((event as TouchEvent).touches[0].clientX - rect.left) / data.value.scale.x
    y = ((event as TouchEvent).touches[0].clientY - rect.top) / data.value.scale.y
  } else {
    x = (event as MouseEvent).offsetX / data.value.scale.x
    y = (event as MouseEvent).offsetY / data.value.scale.y
  }
  return { x, y }
}

const startDraw = (event: MouseEvent | TouchEvent) => {
  if (!props.lock) {
    data.value.drawing = true
    const coordinate = getCoordinates(event)
    data.value.strokes = {
      type: props.eraser ? 'eraser' : props.strokeType,
      from: coordinate,
      coordinates: [],
      color: props.eraser ? props.backgroundColor : props.color,
      width: props.lineWidth,
      fill:
        props.eraser || props.strokeType === 'dash' || props.strokeType === 'line'
          ? false
          : props.fillShape,
      lineCap: props.lineCap as CanvasLineCap,
      lineJoin: props.lineJoin as CanvasLineJoin,
    }
    data.value.guides = []
  }
}

const throttle = (fn: (event: MouseEvent | TouchEvent) => void, limit: number) => {
  let inThrottle: boolean
  return function (this: void, ...args: [MouseEvent | TouchEvent]) {
    if (!inThrottle) {
      fn.apply(this, args)
      inThrottle = true
      setTimeout(() => (inThrottle = false), limit)
    }
  }
}

const draw = throttle((event: MouseEvent | TouchEvent) => {
  if (data.value.drawing) {
    const coordinate = getCoordinates(event)
    if (props.eraser || props.strokeType === 'dash') {
      data.value.strokes.coordinates.push(coordinate)
      if (context.value) {
        drawShape(context.value, data.value.strokes, false)
      }
    } else {
      switch (props.strokeType) {
        case 'line':
          data.value.guides = [{ x: coordinate.x, y: coordinate.y }]
          break
        case 'square':
          data.value.guides = [
            { x: coordinate.x, y: data.value.strokes.from.y },
            { x: coordinate.x, y: coordinate.y },
            { x: data.value.strokes.from.x, y: coordinate.y },
            { x: data.value.strokes.from.x, y: data.value.strokes.from.y },
          ]
          break
        case 'triangle': {
          const center = Math.abs(Math.floor((coordinate.x - data.value.strokes.from.x) / 2))
          const width =
            data.value.strokes.from.x < coordinate.x
              ? data.value.strokes.from.x + center
              : data.value.strokes.from.x - center
          data.value.guides = [
            { x: coordinate.x, y: data.value.strokes.from.y },
            { x: width, y: coordinate.y },
            { x: data.value.strokes.from.x, y: data.value.strokes.from.y },
          ]
          break
        }
        case 'half_triangle':
          data.value.guides = [
            { x: coordinate.x, y: data.value.strokes.from.y },
            { x: data.value.strokes.from.x, y: coordinate.y },
            { x: data.value.strokes.from.x, y: data.value.strokes.from.y },
          ]
          break
        case 'circle': {
          const radiusX = Math.abs(data.value.strokes.from.x - coordinate.x)
          data.value.guides = [
            {
              x:
                data.value.strokes.from.x > coordinate.x
                  ? data.value.strokes.from.x - radiusX
                  : data.value.strokes.from.x + radiusX,
              y: data.value.strokes.from.y,
            },
            { x: radiusX, y: radiusX },
          ]
          break
        }
      }
      drawGuide(true)
    }
  }
}, 16)

const stopDraw = () => {
  if (data.value.drawing) {
    data.value.strokes.coordinates =
      data.value.guides.length > 0 ? data.value.guides : data.value.strokes.coordinates
    data.value.images.push(data.value.strokes)
    redraw(true)
    data.value.drawing = false
    data.value.trash = []
  }
}

const drawGuide = (closingPath: boolean) => {
  redraw(true)
  if (context.value) {
    context.value.strokeStyle = props.color
    context.value.lineWidth = 1
    context.value.lineJoin = props.lineJoin as CanvasLineJoin
    context.value.lineCap = props.lineCap as CanvasLineCap

    context.value.beginPath()
    context.value.setLineDash([15, 15])
    if (data.value.strokes.type === 'circle') {
      context.value.ellipse(
        data.value.guides[0].x,
        data.value.guides[0].y,
        data.value.guides[1].x,
        data.value.guides[1].y,
        0,
        0,
        Math.PI * 2,
      )
    } else {
      context.value.moveTo(data.value.strokes.from.x, data.value.strokes.from.y)
      data.value.guides.forEach((coordinate: { x: number; y: number }) => {
        if (context.value) {
          context.value.lineTo(coordinate.x, coordinate.y)
        }
      })
      if (closingPath) {
        context.value.closePath()
      }
    }
    context.value.stroke()
  }
}

const drawShape = (context: CanvasRenderingContext2D, strokes: Stroke, closingPath: boolean) => {
  context.strokeStyle = strokes.color
  context.fillStyle = strokes.color
  context.lineWidth = strokes.width
  context.lineJoin = strokes.lineJoin || props.lineJoin
  context.lineCap = strokes.lineCap || props.lineCap
  context.beginPath()
  context.setLineDash([])

  if (strokes.type === 'circle') {
    context.ellipse(
      strokes.coordinates[0].x,
      strokes.coordinates[0].y,
      strokes.coordinates[1].x,
      strokes.coordinates[1].y,
      0,
      0,
      Math.PI * 2,
    )
  } else {
    context.moveTo(strokes.from.x, strokes.from.y)
    strokes.coordinates.forEach((stroke: { x: number; y: number }) => {
      context.lineTo(stroke.x, stroke.y)
    })
    if (closingPath) {
      context.closePath()
    }
  }

  if (strokes.fill) {
    context.fill()
  } else {
    context.stroke()
  }
}

const redraw = async (output: boolean) => {
  await setBackground()
  const baseCanvas = document.createElement('canvas')
  const baseCanvasContext = baseCanvas.getContext('2d')
  baseCanvas.width = Number(canvasWidth.value)
  baseCanvas.height = Number(canvasHeight.value)

  if (baseCanvasContext) {
    data.value.images.forEach((stroke: Stroke) => {
      baseCanvasContext.globalCompositeOperation =
        stroke.type === 'eraser' ? 'destination-out' : 'source-over'
      if (stroke.type !== 'circle' || (stroke.type === 'circle' && stroke.coordinates.length > 0)) {
        drawShape(
          baseCanvasContext,
          stroke,
          stroke.type !== 'eraser' && stroke.type !== 'dash' && stroke.type !== 'line',
        )
      }
    })
    if (context.value) {
      context.value.drawImage(
        baseCanvas,
        0,
        0,
        Number(canvasWidth.value),
        Number(canvasHeight.value),
      )
    }
  }

  if (output) {
    save()
  }
}

const save = () => {
  {
    const tempCanvas = document.createElement('canvas')
    const tempCtx = tempCanvas.getContext('2d')
    const tempWidth = props.outputWidth || canvasWidth.value
    const tempHeight = props.outputHeight || canvasHeight.value

    if (tempCtx) {
      tempCanvas.width = Number(tempWidth)
      tempCanvas.height = Number(tempHeight)
      if (canvas.value) {
        tempCtx.drawImage(canvas.value, 0, 0, Number(tempWidth), Number(tempHeight))
      }
      emits('update:image', tempCanvas.toDataURL(`image/${props.saveAs}`, 1))
      return tempCanvas.toDataURL(`image/${props.saveAs}`, 1)
    }
  }
}

defineExpose({
  clear,
  reset,
  undo,
  redo,
  save,
  startDraw,
  draw,
  stopDraw,
  handleResize,
})
</script>

<style scoped>
canvas {
  cursor: crosshair;
}
</style>
Test
ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach } from 'vitest'
import VBsbPad from '@/components/VBsbPad.vue'
import vuetify from '../../plugins/vuetify'

import { VueWrapper } from '@vue/test-utils'

import type { ComponentPublicInstance } from 'vue'

interface VBsbPadProps {
  strokeType?: string
  color?: string
  lineWidth?: number
  backgroundColor?: string
}

let wrapper: VueWrapper<ComponentPublicInstance<VBsbPadProps>>

describe('VBsbPad Component', () => {
  beforeEach(() => {
    wrapper = mount(VBsbPad, {
      global: {
        plugins: [vuetify],
      },
    })
  })

  it('should mount correctly', () => {
    expect(wrapper.exists()).toBe(true)
  })

  it('should render a canvas element', () => {
    const canvas = wrapper.find('canvas')
    expect(canvas.exists()).toBe(true)
  })

  it('should have default props', () => {
    expect(wrapper.props().strokeType).toBe('dash')
    expect(wrapper.props().color).toBe('#000000')
    expect(wrapper.props().lineWidth).toBe(5)
  })

  it('should apply background color correctly', async () => {
    await wrapper.setProps({ backgroundColor: '#FF0000' })
    const canvasElement = wrapper.find('canvas').element
    const context = canvasElement.getContext('2d')
    if (context) {
      context.fillStyle = '#FF0000'
      expect(context.fillStyle).toBe('#FF0000')
    }
  })

  it('should update line width when prop changes', async () => {
    await wrapper.setProps({ lineWidth: 10 })
    expect(wrapper.props().lineWidth).toBe(10)
  })
})