Реализация Whiteboard (совместная доска) на сайте
Совместная доска — это пересечение нескольких технических областей: canvas-рендеринг с высокой частотой кадров, CRDT-синхронизация состояния, cursor presence и UX с инструментами рисования. Ни одну из этих частей нельзя упростить без потерь в пользовательском опыте.
Выбор рендеринг-стека
| Вариант | Подходит для |
|---|---|
| tldraw | Open-source whiteboard SDK, React, встраивается за день |
| Excalidraw | Open-source, хороший UX, но сложно кастомизировать |
| Konva.js | Полный контроль над canvas, React-friendly |
| Fabric.js | Богатый API, но устаревающий |
| SVG + vanilla | Для простых диаграмм без трансформаций |
Для production-продукта с кастомными требованиями — tldraw как основа или Konva.js с нуля. Excalidraw форкается, но стоимость поддержки форка высокая.
Архитектура: состояние доски
Доска — это набор shape-объектов. Каждый shape имеет тип, геометрию и стили:
type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'freehand' | 'image';
interface BaseShape {
id: string;
type: ShapeType;
x: number;
y: number;
rotation: number;
opacity: number;
locked: boolean;
createdBy: string;
updatedAt: number;
}
interface RectShape extends BaseShape {
type: 'rect';
width: number;
height: number;
fill: string;
stroke: string;
strokeWidth: number;
cornerRadius: number;
}
interface FreehandShape extends BaseShape {
type: 'freehand';
points: [number, number][]; // абсолютные координаты
stroke: string;
strokeWidth: number;
pressure: number[]; // для pressure-sensitive рисования
}
interface TextShape extends BaseShape {
type: 'text';
content: string;
fontSize: number;
fontFamily: string;
color: string;
width: number; // для автопереноса
}
type Shape = RectShape | FreehandShape | TextShape; // | ... остальные типы
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) => {
// перерисовываем изменённые shapes
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]];
}
// Во время рисования — обновляем локально каждую точку
// При завершении (pointerup) — упрощаем и синхронизируем
function finishFreehand(shapeId: string, rawPoints: [number, number][]) {
const simplified = rdp(rawPoints, 2.0); // epsilon в пикселях
updateShape(shapeId, { points: simplified });
}
Для плавных кривых из точек — библиотека perfect-freehand:
import getStroke from 'perfect-freehand';
function getFreehandPath(points: [number, number][], options = {}) {
const stroke = getStroke(points, {
size: 8,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
...options,
});
// stroke -> SVG path data
return getSvgPathFromStroke(stroke);
}
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-элементы (toolbar, selection handles, labels) — в HTML поверх. Canvas для рисования, HTML для интерактивности.
// React-компонент доски
const Whiteboard: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
// Resize observer для HiDPI
const ro = new ResizeObserver(() => {
canvas.width = canvas.offsetWidth * devicePixelRatio;
canvas.height = canvas.offsetHeight * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
render(ctx);
});
ro.observe(canvas);
return () => ro.disconnect();
}, []);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
<Toolbar />
<SelectionOverlay />
<CursorLayer /> {/* presence */}
</div>
);
};
History: undo/redo через Yjs
Yjs предоставляет UndoManager:
const undoManager = new Y.UndoManager(yshapes, {
trackedOrigins: new Set([ydoc.clientID]),
captureTimeout: 500, // группировка операций в 500ms
});
// Операции должны помечаться origin
ydoc.transact(() => {
yshapes.set(shape.id, yshape);
}, ydoc.clientID); // <- origin = clientID, попадёт в undo stack
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;
// Вычислить bounding box всех shapes
const shapes = Array.from(yshapes.values()).map(s => shapeFromYMap(s));
const bbox = getBoundingBox(shapes);
// Создать offscreen canvas нужного размера
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);
// Рендерим с offset
renderShapes(ctx, shapes, { x: -bbox.x + 40, y: -bbox.y + 40, zoom: 1 });
return await offscreen.convertToBlob({ type: 'image/png' });
}
Сроки реализации
Встройка tldraw с кастомной синхронизацией через Yjs: 5–7 дней. Whiteboard с нуля на Konva.js с freehand, shapes, viewport, undo, presence и экспортом: 3–4 недели. Добавление video/audio через WebRTC параллельно с доской: ещё неделя.







