Реалізація рисування та анотування з використанням Canvas
Інструменти рисування на Canvas потрібні в системах перевірки документів (анотування PDF), освітніх платформах (дошка вчителя), інструментах дизайну та системах управління проектами з візуальними схемами. Технічно це робота з 2D Context або WebGL над <canvas>, обробка подій покажчика та серіалізація стану.
Архітектура: immediate mode vs retained mode
Immediate mode (чистий Canvas 2D) — рисуємо пікселі безпосередньо. Швидко, без накладних витрат. Складно: немає об'єктної моделі, потрібно писати hit-testing вручну.
Retained mode (Fabric.js, Konva.js) — об'єктна модель над canvas. Кожна фігура — це об'єкт, який можна виділити, перемістити, змінити. Більше витрат пам'яті, але набагато зручніше для редакторів.
Для анотування з виділенням і редагуванням об'єктів — використовуйте retained mode (Konva або Fabric). Для вільного рисування кистками — immediate mode з чутливістю до тиску.
Вільне рисування (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)
// Точка при клику без руху
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'
// Квадратична крива Безьє для гладких ліній
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 обов'язково, інакше браузер перехоплює дотик для прокручування.
Анотування з 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>
)
}
Скасування/Повтор через стек стану
function useUndoRedo<T>(initialState: T) {
const [history, setHistory] = useState<T[]>([initialState])
const [cursor, setCursor] = useState(0)
const current = history[cursor]
function push(newState: T) {
// Обрізаємо історію після поточної позиції (розгалуження)
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 }
}
Експорт анотованого зображення
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')!
// Спочатку фон
ctx.drawImage(bgImage, 0, 0)
// Над ним — анотації з 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)
})
}
Що ми робимо
Аналізуємо сценарій: вільне рисування, анотування документів, схематичні діаграми. Вибираємо підхід (immediate/retained), реалізуємо інструменти (кисть, фігури, текст, стрілки), скасування/повтор, експорт. За необхідності додаємо синхронізацію через WebSocket для спільного рисування.
Тривалість: базовий canvas з кистю та фігурами — 2–3 дні. Анотування PDF з збереженням — 5–7 днів.







