Реалізація Whiteboard (спільна дошка) на сайті

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Whiteboard (спільна дошка) на сайті
Складна
~2-4 тижні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація 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 паралельно з дошкою: ще один тиждень.