|
1 | 1 | // Web vital metrics calculated by 'web-vitals' npm package to be displayed |
2 | 2 | // in Web Metrics tab of Reactime app. |
3 | | -import { current } from '@reduxjs/toolkit'; |
4 | 3 | import { onTTFB, onLCP, onFID, onFCP, onCLS, onINP } from 'web-vitals'; |
5 | 4 |
|
6 | 5 | const MAX_RECONNECT_ATTEMPTS = 5; |
@@ -139,114 +138,126 @@ window.addEventListener('message', (msg) => { |
139 | 138 | } |
140 | 139 | }); |
141 | 140 |
|
142 | | -// -------------- USER INPUT VISUALIZATION (CLICK REPLAY POINTER) -------------- |
| 141 | +// User input visualization: show click position when time traveling (see docs/USER_INPUT_VISUALIZATION_IMPLEMENTATION.md) |
143 | 142 | const REACTIME_POINTER_OVERLAY_ID = 'reactime-pointer-overlay'; |
144 | 143 | const REACTIME_POINTER_STYLES_ID = 'reactime-pointer-styles'; |
| 144 | +const REACTIME_POINTER_VISIBLE_CLASS = 'reactime-pointer-visible'; |
145 | 145 |
|
146 | | -function getOrCreatePointerOverlay() { |
147 | | - let overlay = document.getElementById(REACTIME_POINTER_OVERLAY_ID); |
148 | | - if (!overlay) { |
149 | | - // Inject styles once for pointer overlay (dot + ripple, high contrast) |
150 | | - if (!document.getElementById(REACTIME_POINTER_STYLES_ID)) { |
151 | | - const style = document.createElement('style'); |
152 | | - style.id = REACTIME_POINTER_STYLES_ID; |
153 | | - style.textContent = ` |
154 | | - #${REACTIME_POINTER_OVERLAY_ID} { |
155 | | - position: fixed; |
156 | | - inset: 0; |
157 | | - pointer-events: none; |
158 | | - z-index: 2147483647; |
159 | | - } |
160 | | - #${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-dot { |
161 | | - position: fixed; |
162 | | - width: 22px; |
163 | | - height: 22px; |
164 | | - border-radius: 50%; |
165 | | - background: #0d9488; |
166 | | - border: 3px solid #fff; |
167 | | - box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); |
168 | | - transform: translate(-50%, -50%); |
169 | | - } |
170 | | - #${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-ripple { |
171 | | - position: fixed; |
172 | | - width: 22px; |
173 | | - height: 22px; |
174 | | - border-radius: 50%; |
175 | | - border: 3px solid #14b8a6; |
176 | | - transform: translate(-50%, -50%); |
177 | | - opacity: 0; |
178 | | - } |
179 | | - #${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-dot { |
180 | | - animation: reactime-dot-pulse 2s ease-in-out; |
181 | | - animation-iteration-count: infinite; |
182 | | - } |
183 | | - #${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-ripple { |
184 | | - animation: reactime-ripple 1.2s ease-out; |
185 | | - animation-iteration-count: infinite; |
186 | | - } |
187 | | - @keyframes reactime-dot-pulse { |
188 | | - 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); } |
189 | | - 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); } |
190 | | - 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); } |
191 | | - } |
192 | | - @keyframes reactime-ripple { |
193 | | - 0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.7; } |
194 | | - 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } |
195 | | - } |
196 | | - @media (prefers-reduced-motion: reduce) { |
197 | | - #${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-dot { |
198 | | - animation: reactime-dot-in 0.25s ease-out; |
199 | | - } |
200 | | - #${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-ripple { |
201 | | - animation: none; |
202 | | - opacity: 0; |
203 | | - } |
204 | | - } |
205 | | - @keyframes reactime-dot-in { |
206 | | - from { transform: translate(-50%, -50%) scale(0); opacity: 0; } |
207 | | - to { transform: translate(-50%, -50%) scale(1); opacity: 1; } |
208 | | - } |
209 | | - `; |
210 | | - (document.head || document.documentElement).appendChild(style); |
| 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; |
211 | 183 | } |
212 | | - overlay = document.createElement('div'); |
213 | | - overlay.id = REACTIME_POINTER_OVERLAY_ID; |
214 | | - overlay.setAttribute('aria-hidden', 'true'); |
215 | | - const ripple = document.createElement('div'); |
216 | | - ripple.className = 'reactime-pointer-ripple'; |
217 | | - const dot = document.createElement('div'); |
218 | | - dot.className = 'reactime-pointer-dot'; |
219 | | - overlay.appendChild(ripple); |
220 | | - overlay.appendChild(dot); |
221 | | - overlay.style.display = 'none'; |
222 | | - (document.body || document.documentElement).appendChild(overlay); |
| 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; } |
223 | 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; |
224 | 223 | return overlay; |
225 | 224 | } |
226 | 225 |
|
227 | | -function updateClickReplayPointer(payload) { |
228 | | - const event = payload?.lastUserEvent; |
| 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 { |
229 | 236 | const overlay = getOrCreatePointerOverlay(); |
230 | | - const dot = overlay.querySelector('.reactime-pointer-dot'); |
231 | | - const ripple = overlay.querySelector('.reactime-pointer-ripple'); |
232 | | - if (!dot || !(dot instanceof HTMLElement)) return; |
233 | | - if (event && typeof event.x === 'number' && typeof event.y === 'number') { |
| 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) { |
234 | 246 | const left = `${event.x}px`; |
235 | 247 | const top = `${event.y}px`; |
236 | 248 | dot.style.left = left; |
237 | 249 | dot.style.top = top; |
238 | | - if (ripple && ripple instanceof HTMLElement) { |
| 250 | + if (ripple) { |
239 | 251 | ripple.style.left = left; |
240 | 252 | ripple.style.top = top; |
241 | 253 | } |
242 | 254 | overlay.style.display = ''; |
243 | | - // Remove then re-add visible class so ripple animation plays every time we jump |
244 | | - overlay.classList.remove('reactime-pointer-visible'); |
| 255 | + overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS); |
245 | 256 | requestAnimationFrame(() => { |
246 | | - overlay.classList.add('reactime-pointer-visible'); |
| 257 | + overlay.classList.add(REACTIME_POINTER_VISIBLE_CLASS); |
247 | 258 | }); |
248 | 259 | } else { |
249 | | - overlay.classList.remove('reactime-pointer-visible'); |
| 260 | + overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS); |
250 | 261 | overlay.style.display = 'none'; |
251 | 262 | } |
252 | 263 | } |
|
0 commit comments