Однажды к нам пришёл стартап, который хотел внедрить аннотирование 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 — парадигма графического программирования.







