Canvas Drawing and Annotation Implementation
Canvas drawing tools are needed in document verification systems (PDF annotation), educational platforms (teacher's whiteboard), design tools, and project management systems with visual diagrams. Technically, this involves working with 2D Context or WebGL over <canvas>, handling pointer events, and serializing state.
Architecture: immediate mode vs retained mode
Immediate mode (pure Canvas 2D) — we draw pixels directly. Fast, no overhead. Difficult: no object model, hit-testing has to be written manually.
Retained mode (Fabric.js, Konva.js) — object model over canvas. Each shape is an object that can be selected, moved, modified. Higher memory cost, but much more convenient for editors.
For annotation with shape selection and editing — use retained mode (Konva or Fabric). For freehand drawing with brushes — immediate mode with pressure sensitivity.
Freehand drawing (Canvas 2D)
function DrawingCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const isDrawingRef = useRef(false)
const lastPointRef = useRef<{ x: number; y: number } | null>(null)
const [tool, setTool] = useState<'pen' | 'eraser'>('pen')
const [color, setColor] = useState('#2563eb')
const [lineWidth, setLineWidth] = useState(3)
function getPoint(e: PointerEvent): { x: number; y: number } {
const rect = canvasRef.current!.getBoundingClientRect()
const scaleX = canvasRef.current!.width / rect.width
const scaleY = canvasRef.current!.height / rect.height
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
}
}
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
function onPointerDown(e: PointerEvent) {
canvas.setPointerCapture(e.pointerId)
isDrawingRef.current = true
lastPointRef.current = getPoint(e)
// Point on click without movement
ctx.beginPath()
ctx.arc(lastPointRef.current.x, lastPointRef.current.y, lineWidth / 2, 0, Math.PI * 2)
ctx.fillStyle = tool === 'eraser' ? '#ffffff' : color
ctx.fill()
}
function onPointerMove(e: PointerEvent) {
if (!isDrawingRef.current || !lastPointRef.current) return
const point = getPoint(e)
ctx.globalCompositeOperation = tool === 'eraser' ? 'destination-out' : 'source-over'
ctx.strokeStyle = color
ctx.lineWidth = tool === 'eraser' ? lineWidth * 4 : lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
// Quadratic bezier for smooth lines
ctx.beginPath()
ctx.moveTo(lastPointRef.current.x, lastPointRef.current.y)
ctx.quadraticCurveTo(
lastPointRef.current.x,
lastPointRef.current.y,
(lastPointRef.current.x + point.x) / 2,
(lastPointRef.current.y + point.y) / 2
)
ctx.stroke()
lastPointRef.current = point
}
function onPointerUp() {
isDrawingRef.current = false
lastPointRef.current = null
}
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('pointerup', onPointerUp)
canvas.addEventListener('pointercancel', onPointerUp)
return () => {
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
}
}, [tool, color, lineWidth])
return (
<div>
<canvas
ref={canvasRef}
width={1200}
height={800}
style={{ width: '100%', touchAction: 'none', cursor: 'crosshair' }}
className="border rounded bg-white"
/>
</div>
)
}
touchAction: none is mandatory, otherwise the browser intercepts touch for scrolling.
Annotation with Konva.js
npm install konva react-konva
import { Stage, Layer, Line, Rect, Circle, Text, Transformer } from 'react-konva'
import Konva from 'konva'
type AnnotationType = 'line' | 'rect' | 'circle' | 'arrow' | 'text'
interface Annotation {
id: string
type: AnnotationType
points?: number[]
x?: number
y?: number
width?: number
height?: number
text?: string
color: string
}
function AnnotationTool({ backgroundImage }: { backgroundImage: string }) {
const [annotations, setAnnotations] = useState<Annotation[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState<AnnotationType>('rect')
const [isDrawing, setIsDrawing] = useState(false)
const stageRef = useRef<Konva.Stage>(null)
function getRelativePosition() {
const stage = stageRef.current!
const pos = stage.getPointerPosition()!
return { x: pos.x, y: pos.y }
}
function handleMouseDown() {
setSelectedId(null)
const pos = getRelativePosition()
const newAnnotation: Annotation = {
id: crypto.randomUUID(),
type: activeTool,
color: '#ef4444',
x: pos.x,
y: pos.y,
width: 0,
height: 0,
}
if (activeTool === 'line') {
newAnnotation.points = [pos.x, pos.y, pos.x, pos.y]
}
setAnnotations((prev) => [...prev, newAnnotation])
setIsDrawing(true)
}
function handleMouseMove() {
if (!isDrawing) return
const pos = getRelativePosition()
const lastIndex = annotations.length - 1
const last = annotations[lastIndex]
const updated = { ...last }
if (activeTool === 'line') {
updated.points = [last.points![0], last.points![1], pos.x, pos.y]
} else {
updated.width = pos.x - last.x!
updated.height = pos.y - last.y!
}
setAnnotations((prev) => [...prev.slice(0, lastIndex), updated])
}
function handleMouseUp() {
setIsDrawing(false)
}
return (
<Stage
ref={stageRef}
width={800}
height={600}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<Layer>
{annotations.map((ann) => {
if (ann.type === 'rect') {
return (
<Rect
key={ann.id}
x={ann.x}
y={ann.y}
width={ann.width}
height={ann.height}
stroke={ann.color}
strokeWidth={2}
fill="transparent"
onClick={() => setSelectedId(ann.id)}
draggable={selectedId === ann.id}
/>
)
}
if (ann.type === 'line') {
return (
<Line
key={ann.id}
points={ann.points}
stroke={ann.color}
strokeWidth={2}
lineCap="round"
/>
)
}
return null
})}
</Layer>
</Stage>
)
}
Undo/Redo with state stack
function useUndoRedo<T>(initialState: T) {
const [history, setHistory] = useState<T[]>([initialState])
const [cursor, setCursor] = useState(0)
const current = history[cursor]
function push(newState: T) {
// Trim history after current position (branching)
const newHistory = [...history.slice(0, cursor + 1), newState]
setHistory(newHistory)
setCursor(newHistory.length - 1)
}
function undo() {
if (cursor > 0) setCursor((c) => c - 1)
}
function redo() {
if (cursor < history.length - 1) setCursor((c) => c + 1)
}
return { current, push, undo, redo, canUndo: cursor > 0, canRedo: cursor < history.length - 1 }
}
Export annotated image
function exportAnnotated(stage: Konva.Stage, bgImage: HTMLImageElement) {
const canvas = document.createElement('canvas')
canvas.width = stage.width()
canvas.height = stage.height()
const ctx = canvas.getContext('2d')!
// Background first
ctx.drawImage(bgImage, 0, 0)
// Annotations on top from Konva
const stageCanvas = stage.toCanvas()
ctx.drawImage(stageCanvas, 0, 0)
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob!)
const a = document.createElement('a')
a.href = url
a.download = 'annotated.png'
a.click()
URL.revokeObjectURL(url)
})
}
What we do
Analyze the scenario: freehand drawing, document annotation, schematic diagrams. Choose an approach (immediate/retained), implement tools (brush, shapes, text, arrows), undo/redo, export. If needed, add synchronization via WebSocket for collaborative drawing.
Timeline: basic canvas with brush and shapes — 2–3 days. PDF annotation with saving — 5–7 days.







