11// Web vital metrics calculated by 'web-vitals' npm package to be displayed
22// in Web Metrics tab of Reactime app.
3- import { current } from '@reduxjs/toolkit' ;
43import { onTTFB , onLCP , onFID , onFCP , onCLS , onINP } from 'web-vitals' ;
54
65const MAX_RECONNECT_ATTEMPTS = 5 ;
@@ -139,6 +138,130 @@ window.addEventListener('message', (msg) => {
139138 }
140139} ) ;
141140
141+ // User input visualization: show click position when time traveling (see docs/USER_INPUT_VISUALIZATION_IMPLEMENTATION.md)
142+ const REACTIME_POINTER_OVERLAY_ID = 'reactime-pointer-overlay' ;
143+ const REACTIME_POINTER_STYLES_ID = 'reactime-pointer-styles' ;
144+ const REACTIME_POINTER_VISIBLE_CLASS = 'reactime-pointer-visible' ;
145+
146+ /** Cached refs to avoid repeated DOM lookups after first use */
147+ let pointerOverlayRef : HTMLElement | null = null ;
148+ let pointerDotRef : HTMLElement | null = null ;
149+ let pointerRippleRef : HTMLElement | null = null ;
150+
151+ const REACTIME_POINTER_STYLES = `
152+ #${ REACTIME_POINTER_OVERLAY_ID } {
153+ position: fixed; inset: 0; pointer-events: none; z-index: 2147483647;
154+ }
155+ #${ REACTIME_POINTER_OVERLAY_ID } .reactime-pointer-dot {
156+ position: fixed; width: 22px; height: 22px; border-radius: 50%;
157+ background: #0d9488; border: 3px solid #fff;
158+ box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5);
159+ transform: translate(-50%, -50%);
160+ }
161+ #${ REACTIME_POINTER_OVERLAY_ID } .reactime-pointer-ripple {
162+ position: fixed; width: 22px; height: 22px; border-radius: 50%;
163+ border: 3px solid #14b8a6; transform: translate(-50%, -50%); opacity: 0;
164+ }
165+ #${ REACTIME_POINTER_OVERLAY_ID } .${ REACTIME_POINTER_VISIBLE_CLASS } .reactime-pointer-dot {
166+ animation: reactime-dot-pulse 2s ease-in-out; animation-iteration-count: infinite;
167+ }
168+ #${ REACTIME_POINTER_OVERLAY_ID } .${ REACTIME_POINTER_VISIBLE_CLASS } .reactime-pointer-ripple {
169+ animation: reactime-ripple 1.2s ease-out; animation-iteration-count: infinite;
170+ }
171+ @keyframes reactime-dot-pulse {
172+ 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); }
173+ 10% { transform: translate(-50%, -50%) scale(1); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); }
174+ 50% { transform: translate(-50%, -50%) scale(1.2); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 28px 8px rgba(13,148,136,0.7); }
175+ }
176+ @keyframes reactime-ripple {
177+ 0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.7; }
178+ 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
179+ }
180+ @media (prefers-reduced-motion: reduce) {
181+ #${ REACTIME_POINTER_OVERLAY_ID } .${ REACTIME_POINTER_VISIBLE_CLASS } .reactime-pointer-dot {
182+ animation: reactime-dot-in 0.25s ease-out;
183+ }
184+ #${ REACTIME_POINTER_OVERLAY_ID } .${ REACTIME_POINTER_VISIBLE_CLASS } .reactime-pointer-ripple {
185+ animation: none; opacity: 0;
186+ }
187+ }
188+ @keyframes reactime-dot-in {
189+ from { transform: translate(-50%, -50%) scale(0); opacity: 0; }
190+ to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
191+ }
192+ ` ;
193+
194+ /**
195+ * Returns the pointer overlay element, creating it (and injecting styles) only on first use.
196+ * Reuses cached refs to avoid repeated DOM lookups.
197+ */
198+ function getOrCreatePointerOverlay ( ) : HTMLElement {
199+ if ( pointerOverlayRef ) return pointerOverlayRef ;
200+
201+ if ( ! document . getElementById ( REACTIME_POINTER_STYLES_ID ) ) {
202+ const style = document . createElement ( 'style' ) ;
203+ style . id = REACTIME_POINTER_STYLES_ID ;
204+ style . textContent = REACTIME_POINTER_STYLES ;
205+ ( document . head || document . documentElement ) . appendChild ( style ) ;
206+ }
207+
208+ const overlay = document . createElement ( 'div' ) ;
209+ overlay . id = REACTIME_POINTER_OVERLAY_ID ;
210+ overlay . setAttribute ( 'aria-hidden' , 'true' ) ;
211+ const ripple = document . createElement ( 'div' ) ;
212+ ripple . className = 'reactime-pointer-ripple' ;
213+ const dot = document . createElement ( 'div' ) ;
214+ dot . className = 'reactime-pointer-dot' ;
215+ overlay . appendChild ( ripple ) ;
216+ overlay . appendChild ( dot ) ;
217+ overlay . style . display = 'none' ;
218+ ( document . body || document . documentElement ) . appendChild ( overlay ) ;
219+
220+ pointerOverlayRef = overlay ;
221+ pointerDotRef = dot ;
222+ pointerRippleRef = ripple ;
223+ return overlay ;
224+ }
225+
226+ /** Payload shape we use for click replay (snapshot may include lastUserEvent from backend). */
227+ interface ClickReplayPayload {
228+ lastUserEvent ?: { x : number ; y : number } | null ;
229+ }
230+
231+ /**
232+ * Shows or hides the click-replay pointer on the page based on snapshot payload.
233+ * Uses cached overlay/dot/ripple refs after first run to avoid repeated DOM queries.
234+ */
235+ function updateClickReplayPointer ( payload : ClickReplayPayload | undefined ) : void {
236+ const overlay = getOrCreatePointerOverlay ( ) ;
237+ const dot = pointerDotRef ;
238+ const ripple = pointerRippleRef ;
239+ if ( ! dot ) return ;
240+
241+ const event = payload ?. lastUserEvent ;
242+ const hasValidEvent =
243+ event != null && typeof event . x === 'number' && typeof event . y === 'number' ;
244+
245+ if ( hasValidEvent ) {
246+ const left = `${ event . x } px` ;
247+ const top = `${ event . y } px` ;
248+ dot . style . left = left ;
249+ dot . style . top = top ;
250+ if ( ripple ) {
251+ ripple . style . left = left ;
252+ ripple . style . top = top ;
253+ }
254+ overlay . style . display = '' ;
255+ overlay . classList . remove ( REACTIME_POINTER_VISIBLE_CLASS ) ;
256+ requestAnimationFrame ( ( ) => {
257+ overlay . classList . add ( REACTIME_POINTER_VISIBLE_CLASS ) ;
258+ } ) ;
259+ } else {
260+ overlay . classList . remove ( REACTIME_POINTER_VISIBLE_CLASS ) ;
261+ overlay . style . display = 'none' ;
262+ }
263+ }
264+
142265// FROM BACKGROUND TO CONTENT SCRIPT
143266// Listening for messages from the UI of the Reactime extension.
144267chrome . runtime . onMessage . addListener ( ( request ) => {
@@ -151,12 +274,16 @@ chrome.runtime.onMessage.addListener((request) => {
151274 }
152275 // this is only listening for Jump toSnap
153276 if ( action === 'jumpToSnap' ) {
277+ updateClickReplayPointer ( request . payload ) ;
154278 chrome . runtime . sendMessage ( request ) ;
155279 // After the jumpToSnap action has been sent back to background js,
156280 // it will send the same action to backend files (index.ts) for it execute the jump feature
157281 // '*' == target window origin required for event to be dispatched, '*' = no preference
158282 window . postMessage ( request , '*' ) ;
159283 }
284+ if ( action === 'hideClickReplay' ) {
285+ updateClickReplayPointer ( undefined ) ;
286+ }
160287 if ( action === 'portDisconnect' && ! currentPort && ! isAttemptingReconnect ) {
161288 console . log ( 'Received disconnect message, initiating reconnection' ) ;
162289 // When we receive a port disconnection message, relay it to the window
0 commit comments