| 1 | import type { |
| 2 | DesktopPreviewAnnotationTheme, |
| 3 | DesktopPreviewRecordingInput, |
| 4 | } from "@t3tools/contracts"; |
| 5 | |
| 6 | import { |
| 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 | */ |
| 19 | export 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 | } |