Реализация 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 — полдня.







