/** * LiveCursorOverlay — renders remote users' cursors over an absolutely- * positioned container. * * Cursor positions are expected as normalized coordinates (0–1 relative to * the container's content size) so different zoom/scroll positions map * correctly. Pass a `mapCoords` prop to handle non-identity transforms * (e.g. a zoomed canvas). * * The agent uses the same pointer shape with an "AI" label. * * Cursors fade out after 10 seconds of no movement. */ import { useState, useEffect, useRef, memo, type RefObject } from "react"; import type { OtherPresence, NormalizedPoint } from "./types.js"; export interface CursorMapFn { /** Convert normalized coords to pixel offsets within the overlay container. */ (norm: NormalizedPoint): { x: number; y: number }; } export interface LiveCursorOverlayProps { /** Remote participants with presence payload. */ others: OtherPresence[]; /** * Key inside presence payload that carries the cursor position. * Default: "cursor" * Expected shape: { x: number; y: number } (normalized 0–1). */ cursorKey?: string; /** * Override coordinate mapping. Default: scale by container clientWidth/Height. * Pass this when the container uses transform: scale() or has virtual scroll. */ mapCoords?: CursorMapFn; /** * Container element ref. Required when mapCoords is not provided — * used to compute pixel positions from normalized coords. */ containerRef?: RefObject; /** Additional CSS class for the overlay div. */ className?: string; } const STALE_MS = 10_000; // Fade out cursors older than 10s function CursorPointer({ color }: { color: string }) { return ( ); } interface CursorEntry { other: OtherPresence; x: number; y: number; lastSeen: number; } const CursorLabel = memo(function CursorLabel({ entry, }: { entry: CursorEntry; }) { const { other, x, y } = entry; const color = other.user.color || "#94a3b8"; const label = other.isAgent ? "AI" : other.user.name || other.user.email; return (
{(other.user as { avatarUrl?: string }).avatarUrl ? ( ) : null} {label}
); }); export function LiveCursorOverlay({ others, cursorKey = "cursor", mapCoords, containerRef, className, }: LiveCursorOverlayProps) { const overlayRef = useRef(null); const [, tick] = useState(0); // Force re-render to prune stale cursors const entriesRef = useRef>(new Map()); // Tick every 5s to prune stale cursors (no re-render storm). useEffect(() => { const id = setInterval(() => tick((n) => n + 1), 5_000); return () => clearInterval(id); }, []); // Build entries from others, computing pixel positions. const now = Date.now(); const visible: CursorEntry[] = []; for (const other of others) { const pos = other.presence[cursorKey] as NormalizedPoint | undefined; if (!pos || typeof pos.x !== "number" || typeof pos.y !== "number") continue; // Compute pixel position. let px: number; let py: number; if (mapCoords) { const mapped = mapCoords(pos); px = mapped.x; py = mapped.y; } else { const container = containerRef?.current ?? overlayRef.current?.parentElement; const w = container ? container.clientWidth : 0; const h = container ? container.clientHeight : 0; px = pos.x * w; py = pos.y * h; } const prev = entriesRef.current.get(other.clientId); const lastSeen = prev && prev.x === px && prev.y === py ? prev.lastSeen : now; const entry: CursorEntry = { other, x: px, y: py, lastSeen }; entriesRef.current.set(other.clientId, entry); if (now - lastSeen < STALE_MS) { visible.push(entry); } } // Remove entries for participants who left. for (const clientId of entriesRef.current.keys()) { if (!others.find((o) => o.clientId === clientId)) { entriesRef.current.delete(clientId); } } if (visible.length === 0) { return (
); } return (
{visible.map((entry) => ( ))}
); }