| 1 | "use client"; |
| 2 | |
| 3 | import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; |
| 4 | import { MousePointer2 } from "lucide-react"; |
| 5 | import { useEffect, useState } from "react"; |
| 6 | |
| 7 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; |
| 8 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 9 | |
| 10 | import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; |
| 11 | |
| 12 | const CURSOR_ACTIVE_MS = 700; |
| 13 | |
| 14 | export 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 | |
| 35 | function 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. */ |
| 61 | export 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 | } |