Однажды к нам пришёл стартап, который хотел внедрить аннотирование PDF в веб-приложение. Они использовали SVG-оверлей и на документе в 200 страниц получили лаги — каждый росчерк пера отзывался через секунду. Мы перевели их на Canvas с retained-режимом (Fabric.js) — и скорость отрисовки выросла в 8 раз, а пользователи перестали жаловаться на тормоза. За более чем 5 лет мы реализовали более 50 canvas-инструментов: от простых кистей до полноценных редакторов диаграмм.
Независимо от сценария — свободное рисование, аннотирование документов или схемы — базовая архитектура сводится к выбору между immediate mode и retained mode. Каждый подход имеет свои компромиссы, и мы поможем вам выбрать правильный под ваши задачи.
Технические сложности при разработке Canvas-инструментов
Свободное рисование с pressure sensitivity. Не все браузеры корректно передают давление пера — приходится эмулировать ширину линии через скорость движения пера. В 80% случаев проблема решается за день: добавляем полифилл для Pointer Events и калибруем алгоритм под конкретные устройства (Windows Ink, iPad, Wacom).
Аннотирование PDF. Координаты аннотаций должны быть привязаны к странице, а не к экрану. При зуммировании и скролле позиции пересчитываются с учётом current scale. Мы используем масштабируемые слои и перехватываем события transform stage в Konva.js.
Валидация экспорта. Аннотации должны точно ложиться на оригинал. Мы сначала рендерим PDF как изображение, затем поверх накладываем canvas с аннотациями — так исключаются расхождения в DPI.
Почему retained mode предпочтительнее для аннотирования?
Immediate mode (чистый Canvas 2D) быстр, но требует ручного hit-testing и управления сценами. Retained mode (Fabric.js, Konva.js) предоставляет объектную модель: каждая фигура — объект, который можно выделить, переместить, изменить. Для редакторов с undo/redo и экспортом retained mode сокращает время разработки вдвое. Кроме того, retained mode удобнее для командной работы: изменения в объектной модели легко синхронизировать через WebSocket — это позволяет реализовать совместное рисование онлайн.
| Характеристика | Immediate mode | Retained mode |
|---|---|---|
| Производительность | Высокая (низкий оверхед) | Средняя (зависит от числа объектов) |
| Объектная модель | Нет, пишется вручную | Встроенная (фигуры, группы) |
| Undo/Redo | Громоздкий (строки пикселей) | Простой (снимки объектов) |
| Hit-testing | Координатная математика | Встроенный (на основе bounding box) |
| Подходит для | Freehand, кисти, эластичные линии | Фигуры, текст, выделение объектов |
Как мы реализуем аннотирование с Konva.js
Мы используем Konva.js 9 с React-Konva. Каждая аннотация — объект класса Rect, Line, Circle, Text или Arrow. Пользователь выбирает инструмент, рисует на stage — объект добавляется в массив и рендерится. Выделение, перемещение и редактирование встроенные.
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 в Canvas-редакторе?
Для отмены и повтора действий мы используем стек состояний. Каждая операция создаёт снимок текущих аннотаций и помещает его в историю. Управление через undo и redo с поддержкой ветвления — при новом действии после отмены история обрезается.
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 }; } Процесс разработки Canvas-инструмента
| Этап | Описание | Срок |
|---|---|---|
| Аналитика | Изучаем сценарии: freehand, аннотирование, диаграммы. Определяем формат сериализации (JSON, SVG). | 1 день |
| Проектирование | Выбираем стек (Canvas 2D, Konva, Fabric). Проектируем объектную модель и структуру слоёв. | 1–2 дня |
| Реализация | Разрабатываем инструменты (кисть, фигуры, текст). Интегрируем undo/redo, экспорт, поддержку pointer. | 3–5 дней |
| Тестирование | Проверяем на разных устройствах (iPad, Windows, Android). Исправляем баги с тач-событиями и давлением. | 1–2 дня |
| Деплой | Документируем API, настраиваем сборку, обучаем команду заказчика. | 1 день |
Как мы экспортируем аннотированное изображение
Фон (PDF или изображение) рендерится первым слоем, аннотации — вторым. Затем canvas одного stage конвертируется в Blob через toBlob(). Учитываем HiDPI: умножаем размеры на window.devicePixelRatio.
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); }); } Чек-лист типичных ошибок при разработке Canvas-инструментов
-
touchAction: noneне выставлен — тач-события перехватываются браузером. - Координаты не масштабируются при изменении размера окна — используем resize handler с пересчётом масштаба.
- Undo/Redo хранит полные стейты (фулл-снэпшоты) — для больших документов объём памяти может расти; в production используем команды (Command pattern) с замыканиями.
- Экспорт в PNG без учёта devicePixelRatio — изображение размыто на Retina.
- Не обработано событие pointercancel — при прерывании жеста остаётся артефакт.
Сроки и что входит в работу
Сроки: базовый инструмент с кистью и фигурами — от 3 до 5 дней. Аннотирование PDF с сохранением координат — от 7 до 10 дней. Стоимость рассчитывается индивидуально после анализа вашего ТЗ. За счёт оптимизации архитектуры вы можете сэкономить до 40% времени на разработку.
Отметим: что входит:
- Исходный код с комментариями
- Документация по API (основные методы и события)
- Инструкция по развёртыванию
- 2 часа обучения команды заказчика
- Гарантия 30 дней на исправление багов
Почему выбирают нас: 5+ лет опыта в разработке canvas-решений, 50+ реализованных проектов, сертифицированные разработчики React и Node.js. Обращайтесь к нам за консультацией по архитектуре Canvas — ответим в течение дня.
Для справки: Canvas API — часть WHATWG HTML Living Standard, retained mode — парадигма графического программирования.







