Реалізація Whiteboard (спільної дошки) на веб-сайті
Спільна дошка — це перетин кількох технічних областей: рендеринг canvas з високою частотою кадрів, синхронізація стану CRDT, cursor presence та UX з інструментами рисування. Жодна з цих частин не може бути спрощена без втрат у користувальницькому досвіді.
Вибір стеку рендеринга
| Варіант | Підходить для |
|---|---|
| tldraw | Open-source 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[]; // для рисування чутливого до тиску
}
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-рисування: оптимізація точок
При рисуванні від руки накопичуються сотні точок. Передавати всі — дорого. Використовується алгоритм 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
return getSvgPathFromStroke(stroke);
}
Viewport: pan та zoom
Дошка нескінченна — потрібна трансформація viewport. Всі координати зберігаються у світовому просторі, 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')!;
// ResizeObserver для 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>
);
};
Історія: 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 паралельно з дошкою: ще один тиждень.







