/** * RemoteSelectionRings — renders colored outline rings + name tags over * elements selected by remote participants. * * Each participant's presence payload may contain a `selection` key with an * opaque descriptor (e.g. a CSS selector). The `resolveRect` callback maps * a descriptor to a DOMRect (or null when the element isn't found). Rings * are rendered as absolutely-positioned outlines anchored to the container. * * Usage: *
* {content} * document.querySelector(selector)?.getBoundingClientRect() ?? null} * containerRef={containerRef} * /> *
*/ import { useState, useEffect, useRef, useLayoutEffect, memo, type RefObject, } from "react"; import type { OtherPresence } from "./types.js"; /** * Selection descriptors may be a plain string (treated as the resolver * input) or an object carrying a resolver input plus a human label * ("Editing hero section"). */ export type SelectionDescriptor = string | { selector: string; label?: string }; export interface RemoteSelectionRingsProps { /** Remote participants. */ others: OtherPresence[]; /** * Key inside presence payload that carries the selection descriptor. * Default: "selection" */ selectionKey?: string; /** * Resolver: maps a selection descriptor to a DOMRect relative to the * viewport. Return null when the element is not found. */ resolveRect: (descriptor: string) => DOMRect | null; /** * Container element ref. Rings are positioned relative to this element's * bounding box. */ containerRef: RefObject; /** Additional CSS class for the overlay div. */ className?: string; } interface Ring { clientId: number; color: string; label: string; avatarUrl?: string; isAgent: boolean; rect: { top: number; left: number; width: number; height: number }; } const RingItem = memo(function RingItem({ ring }: { ring: Ring }) { return (
{/* Name tag in top-left corner of the ring */}
{ring.avatarUrl ? ( ) : null} {ring.label}
); }); export function RemoteSelectionRings({ others, selectionKey = "selection", resolveRect, containerRef, className, }: RemoteSelectionRingsProps) { const overlayRef = useRef(null); const [rings, setRings] = useState([]); // Recompute rings whenever others change or on animation frame. const recompute = () => { const container = containerRef.current; if (!container) { setRings([]); return; } const containerRect = container.getBoundingClientRect(); const next: Ring[] = []; for (const other of others) { const raw = other.presence[selectionKey] as | SelectionDescriptor | null | undefined; const selector = typeof raw === "string" ? raw : raw ? raw.selector : undefined; if (!selector || typeof selector !== "string") continue; const selectionLabel = raw && typeof raw === "object" ? raw.label : undefined; const domRect = resolveRect(selector); if (!domRect) continue; // Convert viewport-relative rect to container-relative. const top = domRect.top - containerRect.top; const left = domRect.left - containerRect.left; if ( left + domRect.width < 0 || top + domRect.height < 0 || left > containerRect.width || top > containerRect.height ) { continue; // Out of container bounds — skip. } const baseName = other.isAgent ? "AI" : other.user.name || other.user.email; next.push({ clientId: other.clientId, color: other.user.color || "#94a3b8", label: selectionLabel ? `${baseName} — ${selectionLabel}` : baseName, avatarUrl: (other.user as { avatarUrl?: string }).avatarUrl, isAgent: other.isAgent, rect: { top, left, width: domRect.width, height: domRect.height }, }); } setRings(next); }; // Recompute on scroll/resize of the container to keep rings in sync. useLayoutEffect(() => { recompute(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [others, selectionKey]); useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver(() => recompute()); observer.observe(container); container.addEventListener("scroll", recompute, { passive: true }); window.addEventListener("scroll", recompute, { passive: true }); return () => { observer.disconnect(); container.removeEventListener("scroll", recompute); window.removeEventListener("scroll", recompute); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [containerRef]); return (
{rings.map((ring) => ( ))}
); }