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
Prop | Type | Default | Description |
---|---|---|---|
strokeType | String | 'dash' | Type of stroke to draw (e.g., 'dash', 'line', 'square', 'circle', 'triangle', 'half_triangle'). |
fillShape | Boolean | false | Whether to fill the drawn shape with the selected color. |
image | String | '' | URL of the image to load onto the canvas. |
eraser | Boolean | false | Toggles eraser functionality to remove parts of the drawing. |
color | String | '#000000' | The color of the stroke. |
lineWidth | Number | 5 | The width of the drawn line. |
lineCap | String | 'round' | The style of line ending (e.g., 'round', 'square', 'butt'). |
lineJoin | String | 'miter' | The type of corner created when two lines meet ('miter', 'round', 'bevel'). |
lock | Boolean | false | Locks the canvas to prevent drawing or erasing. |
styles | Object | {} | Additional styles to apply to the canvas. |
classes | [Array, String, Object] | null | CSS classes to apply to the canvas element. |
backgroundColor | String | '#FFFFFF' | The background color of the canvas. |
backgroundImage | String | null | URL of a background image for the canvas. |
saveAs | String | 'png' | The format for saving the drawing ('jpeg' or 'png' ). |
canvasId | String | 'canvas-' + uniqueId | Unique ID for the canvas element. |
initialImage | Array | [] | Initial set of strokes to load into the canvas. |
additionalImages | Array | [] | Additional images to be added on top of the canvas. |
outputWidth | Number | null | The width of the output image. |
outputHeight | Number | null | The height of the output image. |
Emits
Event | Payload | Description |
---|---|---|
update:image | String (Data URL) | Emitted when the canvas is saved, containing the image data in the specified format. |
Exposes
Method | Description |
---|---|
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)
})
})