Реализация Whiteboard (совместная доска) на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация Whiteboard (совместная доска) на сайте
Сложная
~2-4 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Реализация 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 параллельно с доской: ещё неделя.