1"use client";
2
3import type { DesktopPreviewPointerEvent } from "@t3tools/contracts";
4import { MousePointer2 } from "lucide-react";
5import { useEffect, useState } from "react";
6
7import { useBrowserPointerStore } from "~/browser/browserPointerStore";
8import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
9
10import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic";
11
12const CURSOR_ACTIVE_MS = 700;
13
14export function AgentBrowserCursor(props: {
15 readonly tabId: string;
16 readonly zoomFactor: number;
17 readonly controller: BrowserController;
18}) {
19 const { tabId, zoomFactor, controller } = props;
20 const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null);
21 const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null);
22
23 if (!event) return null;
24
25 return (
26 <AgentBrowserCursorEvent
27 event={event}
28 content={content}
29 zoomFactor={zoomFactor}
30 controller={controller}
31 />
32 );
33}
34
35function AgentBrowserCursorEvent(props: {
36 readonly event: DesktopPreviewPointerEvent;
37 readonly content: {
38 readonly x: number;
39 readonly y: number;
40 readonly scale: number;
41 readonly scrollLeft: number;
42 readonly scrollTop: number;
43 } | null;
44 readonly zoomFactor: number;
45 readonly controller: BrowserController;
46}) {
47 const { event, content, zoomFactor, controller } = props;
48 const scale = zoomFactor * (content?.scale ?? 1);
49 return (
50 <AgentCursorMark
51 phase={event.phase}
52 sequence={event.sequence}
53 left={event.x * scale + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}
54 top={event.y * scale + (content?.y ?? 0) - (content?.scrollTop ?? 0)}
55 controller={controller}
56 />
57 );
58}
59
60/** The agent's pointer at a surface position; it fades once the agent stops acting. */
61export function AgentCursorMark(props: {
62 readonly phase: "move" | "click";
63 readonly sequence: number;
64 readonly left: number;
65 readonly top: number;
66 readonly controller: BrowserController;
67}) {
68 const { phase, sequence, left, top, controller } = props;
69 const [inactiveSequence, setInactiveSequence] = useState<number | null>(null);
70 const active = inactiveSequence !== sequence;
71
72 useEffect(() => {
73 const timeout = window.setTimeout(() => setInactiveSequence(sequence), CURSOR_ACTIVE_MS);
74 return () => window.clearTimeout(timeout);
75 }, [sequence]);
76
77 return (
78 <div
79 className="pointer-events-none absolute left-0 top-0 z-40 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
80 style={{
81 opacity: agentBrowserCursorOpacity(active, controller),
82 transform: `translate3d(${left}px, ${top}px, 0)`,
83 }}
84 aria-hidden="true"
85 data-agent-browser-cursor
86 >
87 {phase === "click" ? (
88 <span
89 key={sequence}
90 className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none"
91 />
92 ) : null}
93 <MousePointer2
94 className="relative size-5 -translate-x-0.5 -translate-y-0.5 fill-background text-primary drop-shadow-sm"
95 strokeWidth={2}
96 />
97 </div>
98 );
99}