Синхронизация изменений при совместном редактировании доски — одна из сложнейших задач в веб-разработке. Без правильного стека рендеринга и механизма синхронизации проект рискует увязнуть в багах и низкой производительности. Мы разрабатываем онлайн-доски для командной работы: рисование от руки, стикеры, фигуры, линии и синхронизация в реальном времени. Наши инженеры внедряют whiteboard под ключ с нуля или интегрируют готовые SDK (tldraw, Excalidraw) с кастомной CRDT-синхронизацией. За 5+ лет реализовано более 30 проектов — от простых досок для заметок до полноценных коллаборативных редакторов с WebRTC и аудиозвонками. Экономия времени — до 40% по сравнению с самостоятельной разработкой.
Как выбрать рендеринг-стек для whiteboard?
| Вариант | Скорость разработки | Кастомизация | Производительность (1000 shapes) | Стоимость поддержки |
|---|---|---|---|---|
| tldraw | 1–2 дня | Средняя (SDK) | Высокая (Canvas) | Низкая (open-source) |
| Konva.js | 3–4 недели | Полная | Высокая (Canvas) | Средняя |
| Excalidraw | 1–2 дня (форк) | Низкая (форк сложен) | Средняя (SVG) | Высокая (форк) |
| Fabric.js | 2–3 недели | Средняя | Средняя (Canvas) | Высокая (устаревает) |
| SVG + vanilla | 1–2 недели | Полная | Низкая при >500 shapes | Средняя |
Для production-продукта с кастомными требованиями предпочтительнее tldraw как основа или Konva.js с нуля. Excalidraw форкается, но стоимость поддержки форка высокая. Мы выбираем стек под ваши задачи и бюджет — закажите разработку и получите архитектурную документацию.
Почему CRDT — оптимальный выбор для синхронизации?
CRDT — это структура данных, которая автоматически разрешает конфликты при параллельном редактировании. Для whiteboard мы используем библиотеку Yjs, которая предоставляет CRDT-типы, оптимизированные для совместной работы. Yjs в 3 раза быстрее OT при одновременном редактировании 10 пользователей, что подтверждено бенчмарками.
Архитектура: состояние доски
Доска — это набор shape-объектов. Каждый shape имеет тип, геометрию и стили.
Основные типы фигур
| Тип | Свойства |
|---|---|
| Rect | x, y, width, height, fill, stroke, strokeWidth, cornerRadius |
| Ellipse | x, y, radiusX, radiusY, fill, stroke |
| Line | points, stroke, strokeWidth |
| Arrow | points, stroke, headStyle |
| Text | content, fontSize, fontFamily, color, width |
| Freehand | points, stroke, strokeWidth, pressure |
| Image | src, x, y, width, height |
CRDT для синхронизации shapes
Y.Map идеально подходит — каждый shape хранится по своему id:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const yshapes = ydoc.getMap<Y.Map<unknown>>('shapes');
const provider = new WebsocketProvider(
process.env.WS_URL!,
`board-${boardId}`,
ydoc
);
// Добавление shape
function addShape(shape: Shape) {
const yshape = new Y.Map<unknown>(Object.entries(shape));
yshapes.set(shape.id, yshape);
}
// Обновление (например, при перетаскивании)
function updateShape(id: string, patch: Partial<Shape>) {
const yshape = yshapes.get(id);
if (!yshape) return;
ydoc.transact(() => {
Object.entries(patch).forEach(([key, value]) => {
yshape.set(key, value);
});
});
}
// Удаление
function deleteShape(id: string) {
yshapes.delete(id);
}
// Реактивность
yshapes.observeDeep((events) => {
events.forEach((event) => {
rerenderCanvas(yshapes);
});
});
Freehand drawing: оптимизация точек
При рисовании от руки накапливаются сотни точек. Передавать все — дорого. Используется алгоритм Ramer-Douglas-Peucker для упрощения кривых:
function rdp(points: [number, number][], epsilon: number): [number, number][] {
if (points.length < 3) return points;
let maxDist = 0;
let maxIdx = 0;
const end = points.length - 1;
for (let i = 1; i < end; i++) {
const dist = perpendicularDistance(points[i], points[0], points[end]);
if (dist > maxDist) {
maxDist = dist;
maxIdx = i;
}
}
if (maxDist > epsilon) {
const left = rdp(points.slice(0, maxIdx + 1), epsilon);
const right = rdp(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
return [points[0], points[end]];
}
После завершения рисования вызываем упрощение и синхронизацию. Для плавных кривых используем библиотеку perfect-freehand.
Viewport: pan и zoom
Доска бесконечная — нужен viewport transform. Все координаты хранятся в мировом пространстве, viewport описывает текущий вид:
interface Viewport {
x: number; // смещение
y: number;
zoom: number; // 0.1 – 4.0
}
// Мировые координаты → экранные
function worldToScreen(wx: number, wy: number, vp: Viewport) {
return {
x: wx * vp.zoom + vp.x,
y: wy * vp.zoom + vp.y,
};
}
// Экранные → мировые (для pointer events)
function screenToWorld(sx: number, sy: number, vp: Viewport) {
return {
x: (sx - vp.x) / vp.zoom,
y: (sy - vp.y) / vp.zoom,
};
}
// Zoom к точке (pinch или колёсико)
function zoomAt(vp: Viewport, screenX: number, screenY: number, delta: number): Viewport {
const factor = delta > 0 ? 1.1 : 0.9;
const newZoom = Math.max(0.1, Math.min(4.0, vp.zoom * factor));
const zoomRatio = newZoom / vp.zoom;
return {
x: screenX - (screenX - vp.x) * zoomRatio,
y: screenY - (screenY - vp.y) * zoomRatio,
zoom: newZoom,
};
}
Canvas vs SVG: выбор под нагрузку
При менее 500 shapes SVG работает отлично и упрощает hit-testing. При более 1000 shapes и активном рисовании используем Canvas (2D или WebGL через Pixi.js). Гибридный подход: shapes рендерятся в Canvas, UI-элементы — в HTML поверх.
Реализация Undo/Redo
Yjs предоставляет UndoManager, который группирует операции за 500 мс:
const undoManager = new Y.UndoManager(yshapes, {
trackedOrigins: new Set([ydoc.clientID]),
captureTimeout: 500,
});
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undoManager.undo();
if (e.ctrlKey && e.key === 'y') undoManager.redo();
});
Экспорт доски
async function exportToPNG(boardId: string): Promise<Blob> {
const canvas = document.getElementById('whiteboard-canvas') as HTMLCanvasElement;
const shapes = Array.from(yshapes.values()).map(s => shapeFromYMap(s));
const bbox = getBoundingBox(shapes);
const offscreen = new OffscreenCanvas(bbox.width + 80, bbox.height + 80);
const ctx = offscreen.getContext('2d')!;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, offscreen.width, offscreen.height);
renderShapes(ctx, shapes, { x: -bbox.x + 40, y: -bbox.y + 40, zoom: 1 });
return await offscreen.convertToBlob({ type: 'image/png' });
}
Процесс работы
- Аналитика: изучение пользовательских сценариев, подбор стека.
- Проектирование: архитектура данных, протокол синхронизации, дизайн интерфейса.
- Реализация: разработка canvas-движка, интеграция Yjs, кастомные инструменты.
- Тестирование: unit-тесты, нагрузочное тестирование, проверка рассинхронизации.
- Деплой: Docker-образ, настройка WebSocket, CDN для статики.
Сроки реализации
Встройка tldraw с кастомной синхронизацией через Yjs: 5–7 дней. Whiteboard с нуля на Konva.js с freehand, shapes, viewport, undo, presence и экспортом: 3–4 недели. Добавление video/audio через WebRTC параллельно с доской: ещё неделя. Гарантия на код — исправляем баги бесплатно в течение месяца.
Что входит в работу
- Архитектурная документация (выбор стека, схема данных, протокол синхронизации).
- Исходный код доски с комментариями и тестами критических модулей.
- Интеграция с вашей системой аутентификации и API.
- Деплой на сервер или CDN (Docker-образ, настройка WebSocket).
- Обучение команды (воркшоп по кастомизации и поддержке).
- Гарантия на код — исправляем баги бесплатно в течение месяца.
- Сертифицированные инженеры с опытом 5+ лет.
Получите консультацию инженера — мы оценим ваш проект и предложим оптимальное решение. Свяжитесь с нами, чтобы обсудить детали.







