1import type {
2 DesktopPreviewAnnotationTheme,
3 DesktopPreviewRecordingInput,
4} from "@t3tools/contracts";
5
6import {
7 DEFAULT_RECORDING_INPUT_OPTIONS,
8 recordingKeyLabel,
9 recordingKeysAreSensitive,
10 type RecordingInputOptions,
11 type RecordingKeyPress,
12} from "./RecordingInput.ts";
13
14/**
15 * Chromium's capture cursor uses native window bounds, which do not follow a
16 * webview's CSS placement or scale. Draw it in the guest's coordinate space
17 * while recording, and make the native cursor transparent to avoid two cursors.
18 */
19export function installRecordingCursor(
20 document: Document,
21 window: Window,
22 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,
23 emit: (input: DesktopPreviewRecordingInput) => void = () => {},
24) {
25 const style = document.createElement("style");
26 style.textContent =
27 "html, html * { cursor: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }";
28 const cursor = document.createElement("div");
29 cursor.setAttribute("aria-hidden", "true");
30 cursor.setAttribute("data-t3code-recording-cursor", "");
31 cursor.style.cssText =
32 "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;";
33 cursor.innerHTML =
34 '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="24" viewBox="0 0 16 24"><path d="M1 1v18l4-4 4 8 3-1.5-4-8H15Z" fill="black" stroke="white" stroke-width="1.5" stroke-linejoin="round"/></svg>';
35 const agentCursor = document.createElement("div");
36 agentCursor.setAttribute("aria-hidden", "true");
37 agentCursor.setAttribute("data-t3code-recording-agent-cursor", "");
38 agentCursor.style.cssText =
39 "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;";
40 // Match the MousePointer2 icon used by the live AgentBrowserCursor.
41 agentCursor.innerHTML =
42 '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="var(--recording-cursor-background,white)" stroke="var(--recording-cursor-primary,#2563eb)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:translate(-2px,-2px)"><path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/></svg>';
43 document.documentElement.append(style, cursor, agentCursor);
44 let controller: "human" | "agent" | "none" = "none";
45 let humanPoint: { readonly x: number; readonly y: number } | null = null;
46 const drawHuman = () => {
47 if (!humanPoint) return;
48 cursor.style.transform = `translate(${humanPoint.x}px, ${humanPoint.y}px)`;
49 cursor.style.display = "block";
50 };
51 let agentActive = false;
52 let agentTimer: number | undefined;
53 const setController = (
54 next: typeof controller,
55 point?: { readonly x: number; readonly y: number },
56 ) => {
57 if (point) humanPoint = point;
58 controller = next;
59 if (next === "agent") cursor.style.display = "none";
60 if (next === "human") drawHuman();
61 if (!agentActive) agentCursor.style.opacity = next === "human" ? "0.18" : "0.35";
62 };
63 const setTheme = (
64 theme: Pick<DesktopPreviewAnnotationTheme, "primary" | "background"> | null,
65 ) => {
66 agentCursor.style.setProperty("--recording-cursor-primary", theme?.primary ?? "#2563eb");
67 agentCursor.style.setProperty("--recording-cursor-background", theme?.background ?? "white");
68 };
69 let lastKeyLabel: string | null = null;
70 let pointerHeld = false;
71 let pointerFrame: number | undefined;
72 let pendingPointer: DesktopPreviewRecordingInput | undefined;
73 const cancelPendingPointer = () => {
74 if (pointerFrame !== undefined) window.cancelAnimationFrame(pointerFrame);
75 pointerFrame = undefined;
76 pendingPointer = undefined;
77 };
78 const keyPress = (input: RecordingKeyPress, held = false) => {
79 if (!options.showKeyPresses) return;
80 const label = recordingKeysAreSensitive(document)
81 ? null
82 : recordingKeyLabel(input, /Mac/.test(window.navigator.platform));
83 lastKeyLabel = label;
84 emit({ type: "key", label, held, width: window.innerWidth });
85 };
86 const pointer = (
87 point: { readonly x: number; readonly y: number },
88 phase: "move" | "down" | "up" | "click",
89 ) => {
90 if (!options.showMousePresses || (phase === "move" && !pointerHeld)) return;
91 if (phase === "down") pointerHeld = true;
92 if (phase === "up") pointerHeld = false;
93 const input: DesktopPreviewRecordingInput = {
94 type: "pointer",
95 phase,
96 ...point,
97 width: window.innerWidth,
98 height: window.innerHeight,
99 };
100 if (phase === "move") {
101 pendingPointer = input;
102 pointerFrame ??= window.requestAnimationFrame(() => {
103 pointerFrame = undefined;
104 if (pendingPointer) emit(pendingPointer);
105 pendingPointer = undefined;
106 });
107 } else {
108 cancelPendingPointer();
109 emit(input);
110 }
111 };
112 const move = (
113 point: { readonly x: number; readonly y: number },
114 phase: "move" | "click" = "move",
115 ) => {
116 agentCursor.style.transform = `translate(${point.x}px, ${point.y}px)`;
117 agentCursor.style.display = "block";
118 agentCursor.style.opacity = "1";
119 agentActive = true;
120 window.clearTimeout(agentTimer);
121 agentTimer = window.setTimeout(() => {
122 agentActive = false;
123 agentCursor.style.opacity = controller === "human" ? "0.18" : "0.35";
124 }, 700);
125 pointer(point, phase);
126 };
127 const moveHuman = (point: { readonly x: number; readonly y: number }) => {
128 if (controller === "agent") return;
129 humanPoint = point;
130 drawHuman();
131 };
132 const pointerMove = (event: PointerEvent) => {
133 if (event.pointerType === "touch") return;
134 const point = { x: event.clientX, y: event.clientY };
135 moveHuman(point);
136 pointer(point, "move");
137 };
138 const pointerDown = (event: PointerEvent) => {
139 if (event.pointerType === "touch") return;
140 moveHuman({ x: event.clientX, y: event.clientY });
141 pointer({ x: event.clientX, y: event.clientY }, "down");
142 };
143 const pointerUp = (event: PointerEvent) => {
144 if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up");
145 };
146 const keyDown = (event: KeyboardEvent) => {
147 if (event.isComposing || event.repeat) return;
148 keyPress(event, true);
149 };
150 const keyUp = () => {
151 if (!options.showKeyPresses) return;
152 emit({
153 type: "key",
154 label: recordingKeysAreSensitive(document) ? null : lastKeyLabel,
155 held: false,
156 width: window.innerWidth,
157 });
158 };
159 const hide = () => {
160 cursor.style.display = "none";
161 pointerHeld = false;
162 cancelPendingPointer();
163 if (options.showKeyPresses || options.showMousePresses) emit({ type: "clear" });
164 };
165 const leave = (event: PointerEvent) => {
166 if (event.relatedTarget === null) hide();
167 };
168 window.addEventListener("pointermove", pointerMove, true);
169 window.addEventListener("pointerdown", pointerDown, true);
170 window.addEventListener("pointerup", pointerUp, true);
171 window.addEventListener("pointercancel", pointerUp, true);
172 window.addEventListener("keydown", keyDown, true);
173 window.addEventListener("keyup", keyUp, true);
174 window.addEventListener("pointerout", leave, true);
175 window.addEventListener("blur", hide);
176 return {
177 move,
178 keyPress,
179 setController,
180 setTheme,
181 dispose: () => {
182 window.removeEventListener("pointermove", pointerMove, true);
183 window.removeEventListener("pointerdown", pointerDown, true);
184 window.removeEventListener("pointerup", pointerUp, true);
185 window.removeEventListener("pointercancel", pointerUp, true);
186 window.removeEventListener("keydown", keyDown, true);
187 window.removeEventListener("keyup", keyUp, true);
188 window.removeEventListener("pointerout", leave, true);
189 window.removeEventListener("blur", hide);
190 cancelPendingPointer();
191 window.clearTimeout(agentTimer);
192 cursor.remove();
193 agentCursor.remove();
194 style.remove();
195 },
196 };
197}