Skip to content
Source
Sign inSign up

RecordingCursor.ts

221 lines9.2 KB

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// Modal dialogs, popovers and fullscreen elements paint above any z-index, so the cursors live in
15// the top layer as manual popovers and move back to its front whenever the page adds to it.
16const POPOVER_RESET =
17 "all:initial;margin:0;padding:0;border:0;background:transparent;overflow:visible;inset:auto;animation:none;transition:none;";
18​
19/** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */
20export function installRecordingCursor(
21 document: Document,
22 window: Window,
23 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,
24 emit: (input: DesktopPreviewRecordingInput) => void = () => {},
25) {
26 const style = document.createElement("style");
27 style.textContent =
28 "html, html * { cursor: none !important; } [data-t3code-recording-cursor]::backdrop, [data-t3code-recording-agent-cursor]::backdrop { display: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }";
29 const cursor = document.createElement("div");
30 cursor.setAttribute("aria-hidden", "true");
31 cursor.setAttribute("data-t3code-recording-cursor", "");
32 cursor.popover = "manual";
33 cursor.style.cssText =
34 POPOVER_RESET +
35 "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;";
36 cursor.innerHTML =
37 '<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>';
38 const agentCursor = document.createElement("div");
39 agentCursor.setAttribute("aria-hidden", "true");
40 agentCursor.setAttribute("data-t3code-recording-agent-cursor", "");
41 agentCursor.popover = "manual";
42 agentCursor.style.cssText =
43 POPOVER_RESET +
44 "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;";
45 agentCursor.innerHTML =
46 '<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>';
47 document.documentElement.append(style, cursor, agentCursor);
48 const cursors = [cursor, agentCursor];
49 const raise = () => {
50 for (const element of cursors) {
51 if (!element.isConnected || typeof element.showPopover !== "function") continue;
52 if (element.matches(":popover-open")) element.hidePopover();
53 element.showPopover();
54 }
55 };
56 raise();
57 const dialogObserver = new MutationObserver(raise);
58 dialogObserver.observe(document, { subtree: true, attributes: true, attributeFilter: ["open"] });
59 const toggle = (event: Event) => {
60 if (cursors.some((element) => element === event.target)) return;
61 if ("newState" in event && event.newState === "open") raise();
62 };
63 document.addEventListener("toggle", toggle, true);
64 document.addEventListener("fullscreenchange", raise);
65 let controller: "human" | "agent" | "none" = "none";
66 let humanPoint: { readonly x: number; readonly y: number } | null = null;
67 const drawHuman = () => {
68 if (!humanPoint) return;
69 cursor.style.transform = `translate(${humanPoint.x}px, ${humanPoint.y}px)`;
70 cursor.style.display = "block";
71 };
72 let agentActive = false;
73 let agentTimer: number | undefined;
74 const setController = (
75 next: typeof controller,
76 point?: { readonly x: number; readonly y: number },
77 ) => {
78 if (point) humanPoint = point;
79 controller = next;
80 if (next === "agent") cursor.style.display = "none";
81 if (next === "human") drawHuman();
82 if (!agentActive) agentCursor.style.opacity = next === "human" ? "0.18" : "0.35";
83 };
84 const setTheme = (
85 theme: Pick<DesktopPreviewAnnotationTheme, "primary" | "background"> | null,
86 ) => {
87 agentCursor.style.setProperty("--recording-cursor-primary", theme?.primary ?? "#2563eb");
88 agentCursor.style.setProperty("--recording-cursor-background", theme?.background ?? "white");
89 };
90 let lastKeyLabel: string | null = null;
91 let pointerHeld = false;
92 let pointerFrame: number | undefined;
93 let pendingPointer: DesktopPreviewRecordingInput | undefined;
94 const cancelPendingPointer = () => {
95 if (pointerFrame !== undefined) window.cancelAnimationFrame(pointerFrame);
96 pointerFrame = undefined;
97 pendingPointer = undefined;
98 };
99 const keyPress = (input: RecordingKeyPress, held = false) => {
100 if (!options.showKeyPresses) return;
101 const label = recordingKeysAreSensitive(document)
102 ? null
103 : recordingKeyLabel(input, /Mac/.test(window.navigator.platform));
104 lastKeyLabel = label;
105 emit({ type: "key", label, held, width: window.innerWidth });
106 };
107 const pointer = (
108 point: { readonly x: number; readonly y: number },
109 phase: "move" | "down" | "up" | "click",
110 ) => {
111 if (!options.showMousePresses || (phase === "move" && !pointerHeld)) return;
112 if (phase === "down") pointerHeld = true;
113 if (phase === "up") pointerHeld = false;
114 const input: DesktopPreviewRecordingInput = {
115 type: "pointer",
116 phase,
117 ...point,
118 width: window.innerWidth,
119 height: window.innerHeight,
120 };
121 if (phase === "move") {
122 pendingPointer = input;
123 pointerFrame ??= window.requestAnimationFrame(() => {
124 pointerFrame = undefined;
125 if (pendingPointer) emit(pendingPointer);
126 pendingPointer = undefined;
127 });
128 } else {
129 cancelPendingPointer();
130 emit(input);
131 }
132 };
133 const move = (
134 point: { readonly x: number; readonly y: number },
135 phase: "move" | "click" = "move",
136 ) => {
137 agentCursor.style.transform = `translate(${point.x}px, ${point.y}px)`;
138 agentCursor.style.display = "block";
139 agentCursor.style.opacity = "1";
140 agentActive = true;
141 window.clearTimeout(agentTimer);
142 agentTimer = window.setTimeout(() => {
143 agentActive = false;
144 agentCursor.style.opacity = controller === "human" ? "0.18" : "0.35";
145 }, 700);
146 pointer(point, phase);
147 };
148 const moveHuman = (point: { readonly x: number; readonly y: number }) => {
149 if (controller === "agent") return;
150 humanPoint = point;
151 drawHuman();
152 };
153 const pointerMove = (event: PointerEvent) => {
154 if (event.pointerType === "touch") return;
155 const point = { x: event.clientX, y: event.clientY };
156 moveHuman(point);
157 pointer(point, "move");
158 };
159 const pointerDown = (event: PointerEvent) => {
160 if (event.pointerType === "touch") return;
161 moveHuman({ x: event.clientX, y: event.clientY });
162 pointer({ x: event.clientX, y: event.clientY }, "down");
163 };
164 const pointerUp = (event: PointerEvent) => {
165 if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up");
166 };
167 const keyDown = (event: KeyboardEvent) => {
168 if (event.isComposing || event.repeat) return;
169 keyPress(event, true);
170 };
171 const keyUp = () => {
172 if (!options.showKeyPresses) return;
173 emit({
174 type: "key",
175 label: recordingKeysAreSensitive(document) ? null : lastKeyLabel,
176 held: false,
177 width: window.innerWidth,
178 });
179 };
180 const hide = () => {
181 cursor.style.display = "none";
182 pointerHeld = false;
183 cancelPendingPointer();
184 if (options.showKeyPresses || options.showMousePresses) emit({ type: "clear" });
185 };
186 const leave = (event: PointerEvent) => {
187 if (event.relatedTarget === null) hide();
188 };
189 window.addEventListener("pointermove", pointerMove, true);
190 window.addEventListener("pointerdown", pointerDown, true);
191 window.addEventListener("pointerup", pointerUp, true);
192 window.addEventListener("pointercancel", pointerUp, true);
193 window.addEventListener("keydown", keyDown, true);
194 window.addEventListener("keyup", keyUp, true);
195 window.addEventListener("pointerout", leave, true);
196 window.addEventListener("blur", hide);
197 return {
198 move,
199 keyPress,
200 setController,
201 setTheme,
202 dispose: () => {
203 window.removeEventListener("pointermove", pointerMove, true);
204 window.removeEventListener("pointerdown", pointerDown, true);
205 window.removeEventListener("pointerup", pointerUp, true);
206 window.removeEventListener("pointercancel", pointerUp, true);
207 window.removeEventListener("keydown", keyDown, true);
208 window.removeEventListener("keyup", keyUp, true);
209 window.removeEventListener("pointerout", leave, true);
210 window.removeEventListener("blur", hide);
211 dialogObserver.disconnect();
212 document.removeEventListener("toggle", toggle, true);
213 document.removeEventListener("fullscreenchange", raise);
214 cancelPendingPointer();
215 window.clearTimeout(agentTimer);
216 cursor.remove();
217 agentCursor.remove();
218 style.remove();
219 },
220 };