Реализация Cursor Presence (курсоры пользователей) на сайте

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

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

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация Cursor Presence (курсоры пользователей) на сайте
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • 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

Реализация Cursor Presence (курсоры пользователей) на сайте

Cursor presence — отображение курсоров и выделений других участников в режиме реального времени. Визуально это несложно; сложность скрыта в трёх вещах: эффективной передаче координат, интерполяции движений и корректном маппинге позиций при изменении контента.

Модель данных presence

Каждый клиент транслирует своё состояние: позицию курсора, активный элемент, возможно — выделение текста. Структура минимальна:

interface UserPresence {
  userId:    string;
  name:      string;
  color:     string;   // уникальный цвет пользователя
  cursor: {
    x:       number;   // viewport-relative или document-relative
    y:       number;
  } | null;
  selection?: {        // для текстовых редакторов
    anchor:  number;
    head:    number;
  };
  activeElement?: string; // id элемента, с которым работает
  lastSeen:  number;   // timestamp для TTL
}

Передача через WebSocket: throttle обязателен

Mousemove генерирует 50–100 событий в секунду. Без throttle трафик от 10 пользователей — тысячи сообщений/сек на сервер. Разумный предел — 30 fps (33ms).

import { throttle } from 'lodash-es';

const sendCursor = throttle((x: number, y: number) => {
  socket.emit('cursor:move', { x, y });
}, 33);

document.addEventListener('mousemove', (e) => {
  sendCursor(e.clientX, e.clientY);
});

document.addEventListener('mouseleave', () => {
  socket.emit('cursor:leave');
});

На сервере (Socket.IO) — broadcast в комнату, исключая отправителя:

socket.on('cursor:move', (data: { x: number; y: number }) => {
  socket.to(roomId).emit('cursor:update', {
    userId: socket.data.userId,
    ...data,
  });
});

socket.on('cursor:leave', () => {
  socket.to(roomId).emit('cursor:remove', {
    userId: socket.data.userId,
  });
});

socket.on('disconnect', () => {
  socket.to(roomId).emit('cursor:remove', {
    userId: socket.data.userId,
  });
});

Рендеринг курсоров: interpolation

Прямое обновление позиции по каждому событию даёт дёргающиеся курсоры. CSS-переход transition: transform 0.1s linear даёт задержку в обратную сторону. Правильно — linear interpolation (lerp) в requestAnimationFrame:

interface RemoteCursor {
  userId:    string;
  name:      string;
  color:     string;
  current:   { x: number; y: number };
  target:    { x: number; y: number };
  el:        HTMLElement;
}

const cursors = new Map<string, RemoteCursor>();

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

function animateCursors() {
  cursors.forEach((cursor) => {
    cursor.current.x = lerp(cursor.current.x, cursor.target.x, 0.35);
    cursor.current.y = lerp(cursor.current.y, cursor.target.y, 0.35);
    cursor.el.style.transform =
      `translate(${cursor.current.x}px, ${cursor.current.y}px)`;
  });
  requestAnimationFrame(animateCursors);
}

animateCursors();

// При получении обновления — только меняем target
socket.on('cursor:update', ({ userId, x, y, name, color }) => {
  if (!cursors.has(userId)) {
    const el = createCursorElement(userId, name, color);
    document.body.appendChild(el);
    cursors.set(userId, {
      userId, name, color,
      current: { x, y },
      target:  { x, y },
      el,
    });
  } else {
    cursors.get(userId)!.target = { x, y };
  }
});

HTML-элемент курсора

function createCursorElement(userId: string, name: string, color: string): HTMLElement {
  const wrapper = document.createElement('div');
  wrapper.style.cssText = `
    position: fixed;
    top: 0; left: 0;
    pointer-events: none;
    z-index: 9999;
    will-change: transform;
  `;
  wrapper.innerHTML = `
    <svg width="16" height="20" viewBox="0 0 16 20" fill="none">
      <path d="M0 0L0 16L4 12L7 18L9 17L6 11L11 11Z"
            fill="${color}" stroke="white" stroke-width="1"/>
    </svg>
    <span style="
      background: ${color};
      color: white;
      font-size: 11px;
      padding: 2px 6px;
      border-radius: 4px;
      white-space: nowrap;
      margin-left: 12px;
      margin-top: -4px;
      display: inline-block;
      font-family: system-ui;
    ">${name}</span>
  `;
  return wrapper;
}

Presence через Yjs Awareness

Если проект уже использует Yjs, Awareness — встроенный механизm presence, не требующий отдельной логики:

import { WebsocketProvider } from 'y-websocket';

const provider = new WebsocketProvider(wsUrl, roomName, ydoc);

// Установка своего состояния
provider.awareness.setLocalState({
  user: {
    id:    currentUser.id,
    name:  currentUser.name,
    color: generateColor(currentUser.id),
  },
  cursor: null,
});

// Обновление позиции курсора
document.addEventListener('mousemove', throttle((e) => {
  provider.awareness.setLocalStateField('cursor', {
    x: e.clientX,
    y: e.clientY,
  });
}, 33));

// Подписка на изменения
provider.awareness.on('change', ({ added, updated, removed }) => {
  const states = provider.awareness.getStates();

  [...added, ...updated].forEach((clientId) => {
    if (clientId === provider.awareness.clientID) return;
    const state = states.get(clientId);
    if (state?.cursor) {
      updateCursor(clientId, state.user, state.cursor);
    }
  });

  removed.forEach((clientId) => {
    removeCursor(clientId);
  });
});

Координаты: viewport vs document

Если страница скроллится, хранить viewport-координаты (clientX/Y) недостаточно — при скролле чужие курсоры сдвинутся. Нужны document-relative координаты:

document.addEventListener('mousemove', throttle((e) => {
  provider.awareness.setLocalStateField('cursor', {
    x: e.clientX + window.scrollX,
    y: e.clientY + window.scrollY,
  });
}, 33));

// При рендеринге — переводим обратно
function getCursorViewportPos(docX: number, docY: number) {
  return {
    x: docX - window.scrollX,
    y: docY - window.scrollY,
  };
}

Курсоры в текстовых редакторах

Позиция в тексте — не координата, а смещение (character offset). Маппинг на экранную позицию через Range API:

function getCaretCoordinates(offset: number): { x: number; y: number } | null {
  const range = document.createRange();
  const editorEl = document.getElementById('editor')!;
  let charCount = 0;

  function findNode(node: Node): boolean {
    if (node.nodeType === Node.TEXT_NODE) {
      const len = node.textContent!.length;
      if (charCount + len >= offset) {
        range.setStart(node, offset - charCount);
        range.collapse(true);
        return true;
      }
      charCount += len;
    } else {
      for (const child of node.childNodes) {
        if (findNode(child)) return true;
      }
    }
    return false;
  }

  if (!findNode(editorEl)) return null;

  const rect = range.getBoundingClientRect();
  return { x: rect.left, y: rect.top };
}

Для ProseMirror и CodeMirror — готовые утилиты (view.coordsAtPos()), не нужно делать вручную.

TTL и cleanup

Если пользователь закрыл вкладку без явного disconnect (например, потерял связь), его курсор останется на экране. Решение — heartbeat + TTL на клиенте:

const CURSOR_TTL = 5000; // 5 секунд без обновлений

const lastSeen = new Map<string, number>();

socket.on('cursor:update', ({ userId, ...pos }) => {
  lastSeen.set(userId, Date.now());
  updateCursor(userId, pos);
});

setInterval(() => {
  const now = Date.now();
  lastSeen.forEach((ts, userId) => {
    if (now - ts > CURSOR_TTL) {
      removeCursor(userId);
      lastSeen.delete(userId);
    }
  });
}, 1000);

Реализация cursor presence с нуля — 1–2 дня. Если уже используется Yjs или Socket.IO, через awareness/broadcast — полдня.