1"use client";
2
3
28import { useCallback, useEffect, useReducer, useRef } from "react";
29import { cn } from "@/lib/utils";
30import { presenceColors } from "@/lib/presence-color";
31import { resolvePresenceCursors } from "@/lib/presence-cursor";
32import { resolvePresencePoint } from "@/lib/presence-editor-views";
33import type { Presence } from "@/lib/types";
34
35export interface CursorLayerProps {
36
37 peers: readonly Presence[];
38
39 blockIds: readonly string[];
40 className?: string;
41}
42
43const RESOLVE_INTERVAL_MS = 400;
44
45export function CursorLayer({ peers, blockIds, className }: CursorLayerProps) {
46 const rootRef = useRef<HTMLDivElement>(null);
47 const [tick, force] = useReducer((n: number) => n + 1, 0);
48
49 const blockOrderRef = useRef(blockIds);
50 blockOrderRef.current = blockIds;
51 const peersRef = useRef(peers);
52 peersRef.current = peers;
53
54
55
56 const refresh = useCallback((): void => force(), []);
57
58 useEffect(() => {
59 const onScroll = (): void => refresh();
60 const onResize = (): void => refresh();
61 window.addEventListener("scroll", onScroll, true);
62 window.addEventListener("resize", onResize);
63 const id = window.setInterval(refresh, RESOLVE_INTERVAL_MS);
64 return () => {
65 window.removeEventListener("scroll", onScroll, true);
66 window.removeEventListener("resize", onResize);
67 window.clearInterval(id);
68 };
69 }, [refresh]);
70
71
72 const lookup = (blockId: string, offset: number | "end") => {
73 const root = rootRef.current;
74 if (!root) return null;
75 const pt = resolvePresencePoint(blockId, offset);
76 if (pt === null) return null;
77 const r = root.getBoundingClientRect();
78 return { x: pt.x - r.left, y: pt.y - r.top };
79 };
80
81 const { cursors } = resolvePresenceCursors(peers, blockOrderRef.current, lookup);
82
83
84 void tick;
85
86
87
88
89
90 return (
91 <div
92 ref={rootRef}
93 className={cn("pointer-events-none absolute inset-0", className)}
94 aria-hidden
95 >
96 {}
97 {cursors.flatMap(({ peer, rects }) =>
98 rects.map((r, i) => (
99 <span
100 key={`${peer.userId}-sel-${i}`}
101 className="pointer-events-none absolute -z-10 rounded-[1px]"
102 style={{
103 left: r.x1,
104 top: r.y1,
105 width: Math.max(1, r.x2 - r.x1),
106 height: Math.max(1, r.y2 - r.y1),
107 backgroundColor: presenceColors(peer.color).soft,
108 }}
109 />
110 )),
111 )}
112
113 {}
115 {cursors.map(({ peer, caret }) => (
116 <span
117 key={peer.userId}
118 className="pointer-events-none absolute"
119 style={{ left: caret.x, top: caret.y }}
120 >
121 <span
122 className="absolute -left-px -top-px block h-4 w-0.5"
123 style={{ backgroundColor: presenceColors(peer.color).solid }}
124 />
125 <span
126 className="absolute left-0 top-4 whitespace-nowrap rounded px-1 py-px text-[10px] font-medium text-white"
127 style={{ backgroundColor: presenceColors(peer.color).labelBg }}
128 >
129 {peer.name}
130 </span>
131 </span>
132 ))}
133 </div>
134 );
135}
136