"use client"; /** * CursorLayer (TASK-0093, spec R4) — the remote-cursor overlay for a page: * one absolutely-positioned caret + MANDATORY name label per resolvable * peer, plus translucent selection highlights in the peer's color. * * - **Decorative**: the layer is `aria-hidden` (cursors are visual; the * announcements live in the separate polite live region, spec R5) — the * name label is for SIGHTED users (the a11y rule: color is never the * sole identifier, and the label is mandatory). * - **Reduced motion**: the cursor position updates are instant (no * CSS transition on `left`/`top` — the global `prefers-reduced-motion` * rule also zeroes any transition, so motion-sensitive users get * instant jumps, spec R4). * - **Stale cursors drop silently**: a cursor whose block is gone * (deleted/moved concurrently) resolves to null and is omitted (the * pure resolver counts it in `dropped`); the peer's next caret move * re-places it. No crash, no orphan node. * - Re-resolves on scroll + resize + a light interval (block content * changes shift the geometry; the interval keeps it fresh without a * scroll listener on every editor). * * The layer is a sibling of the block list inside the page's relative * container (see PageView) — it is NOT inside a block, so z-index + * pointer-events-none keep it out of the editor's way. */ import { useCallback, useEffect, useReducer, useRef } from "react"; import { cn } from "@/lib/utils"; import { presenceColors } from "@/lib/presence-color"; import { resolvePresenceCursors } from "@/lib/presence-cursor"; import { resolvePresencePoint } from "@/lib/presence-editor-views"; import type { Presence } from "@/lib/types"; export interface CursorLayerProps { /** The page's OTHER peers (self excluded). */ peers: readonly Presence[]; /** The block ids in document order (for multi-block selection ranges). */ blockIds: readonly string[]; className?: string; } const RESOLVE_INTERVAL_MS = 400; export function CursorLayer({ peers, blockIds, className }: CursorLayerProps) { const rootRef = useRef(null); const [tick, force] = useReducer((n: number) => n + 1, 0); const blockOrderRef = useRef(blockIds); blockOrderRef.current = blockIds; const peersRef = useRef(peers); peersRef.current = peers; // Re-resolve the geometry: on scroll (the container or window), on // resize, and on a light interval (block edits shift positions). const refresh = useCallback((): void => force(), []); useEffect(() => { const onScroll = (): void => refresh(); const onResize = (): void => refresh(); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); const id = window.setInterval(refresh, RESOLVE_INTERVAL_MS); return () => { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); window.clearInterval(id); }; }, [refresh]); // Resolve in the ROOT's coordinate space (viewport point − root rect). const lookup = (blockId: string, offset: number | "end") => { const root = rootRef.current; if (!root) return null; const pt = resolvePresencePoint(blockId, offset); if (pt === null) return null; const r = root.getBoundingClientRect(); return { x: pt.x - r.left, y: pt.y - r.top }; }; const { cursors } = resolvePresenceCursors(peers, blockOrderRef.current, lookup); // `tick` is the resolution trigger (the effect above re-renders on // scroll/resize/interval; the refs keep the latest data). void tick; // The root is an ABSOLUTE overlay filling the (relative) container that // also holds the block list — its box IS the coordinate space, so the // resolved points land on the right pixels. pointer-events-none keeps it // out of the editor's way; aria-hidden (decorative — spec R4/R5). return (
{/* Selection highlights (translucent, the peer's color). */} {cursors.flatMap(({ peer, rects }) => rects.map((r, i) => ( )), )} {/* Carets + the mandatory name labels (sighted users; the layer is decorative for screen readers — the live region announces). */} {cursors.map(({ peer, caret }) => ( {peer.name} ))}
); }