Skip to content

Commit 481013b

Browse files
authored
Merge pull request #361 from Agboolafeyikemi/feature/user-input-visualization
feat: User input visualization (click replay) when time traveling
2 parents ad96122 + b2a08f4 commit 481013b

8 files changed

Lines changed: 234 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ src/extension/build.pem
1313
bower_components
1414
sandboxes/manual-tests/NextJS/.next
1515
.vscode
16+
.cursor
1617
package-lock.json
1718
yarn.lock
1819
docs/**/*

src/app/containers/MainContainer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ function MainContainer(): JSX.Element {
7777
break;
7878
}
7979
case 'sendSnapshots': {
80-
dispatch(setTab(payload));
80+
// sourceTab is the tab that sent the snapshot; setTab expects tabId, not tabsObj
81+
if (typeof sourceTab === 'number') dispatch(setTab(sourceTab));
8182
dispatch(addNewSnapshots(payload));
8283
break;
8384
}

src/app/slices/mainSlice.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ export const mainSlice = createSlice({
5050
const currSnapshot = tabs[currentTab].snapshots[tabs[currentTab].currLocation.index]; // current snapshot
5151
const currAxSnapshot = tabs[currentTab].axSnapshots[tabs[currentTab].currLocation.index]; // current accessibility tree snapshot
5252

53-
tabs[currentTab].hierarchy.stateSnapshot = { ...currSnapshot }; // resets hierarchy to current snapshot
53+
// Strip lastUserEvent so jumpToSnap (from changeSlider) won't re-show the pointer
54+
const cleanSnapshot = { ...currSnapshot };
55+
delete (cleanSnapshot as { lastUserEvent?: unknown }).lastUserEvent;
56+
57+
tabs[currentTab].hierarchy.stateSnapshot = { ...cleanSnapshot }; // resets hierarchy to current snapshot
5458
tabs[currentTab].hierarchy.axSnapshot = { ...currAxSnapshot }; // resets hierarchy to current accessibility tree snapshot
5559
tabs[currentTab].hierarchy.children = []; // resets hierarchy
56-
tabs[currentTab].snapshots = [currSnapshot]; // resets snapshots to current snapshot
60+
tabs[currentTab].snapshots = [cleanSnapshot]; // resets snapshots to current snapshot (no lastUserEvent)
5761
tabs[currentTab].axSnapshots = [currAxSnapshot]; // resets snapshots to current snapshot
5862

5963
// resets currLocation to current snapshot
@@ -200,19 +204,21 @@ export const mainSlice = createSlice({
200204
const { port, currentTab, tabs } = state;
201205
const { hierarchy, snapshots } = tabs[currentTab] || {};
202206

203-
// finds the name by the action.payload parsing through the hierarchy to send to background.js the current name in the jump action
204-
const nameFromIndex = findName(action.payload, hierarchy);
205-
// nameFromIndex is a number based on which jump button is pushed
207+
const index = typeof action.payload === 'object' ? action.payload.index : action.payload;
208+
209+
// finds the name by the index parsing through the hierarchy to send to background.js the current name in the jump action
210+
const nameFromIndex = findName(index, hierarchy);
206211

212+
// Always pass full snapshot so laser pointer shows when snapshot has lastUserEvent
207213
port.postMessage({
208214
action: 'jumpToSnap',
209-
payload: snapshots[action.payload],
210-
index: action.payload,
215+
payload: snapshots[index],
216+
index,
211217
name: nameFromIndex,
212218
tabId: currentTab,
213219
});
214220

215-
tabs[currentTab].sliderIndex = action.payload;
221+
tabs[currentTab].sliderIndex = index;
216222
},
217223

218224
setCurrentTabInApp: (state, action) => {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Captures the last user interaction (click) in the target page so we can
3+
* attach it to snapshots and show a "laser pointer" replay when time traveling.
4+
* Uses viewport coordinates (clientX, clientY) for the overlay.
5+
*/
6+
7+
export interface LastUserEvent {
8+
type: 'click';
9+
x: number;
10+
y: number;
11+
timestamp: number;
12+
}
13+
14+
let lastUserEvent: LastUserEvent | null = null;
15+
16+
function handleClick(e: MouseEvent): void {
17+
lastUserEvent = {
18+
type: 'click',
19+
x: e.clientX,
20+
y: e.clientY,
21+
timestamp: Date.now(),
22+
};
23+
}
24+
25+
/**
26+
* Returns the most recent user click event (viewport coordinates).
27+
* Used when building a snapshot payload so we can show where the user clicked.
28+
*/
29+
export function getLastUserEvent(): LastUserEvent | null {
30+
return lastUserEvent ? { ...lastUserEvent } : null;
31+
}
32+
33+
/**
34+
* Attach document-level click listener. Call once when the backend initializes.
35+
*/
36+
export function initUserEventCapture(): void {
37+
if (typeof document === 'undefined') return;
38+
document.addEventListener('click', handleClick, true);
39+
}

src/backend/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import 'regenerator-runtime/runtime';
99
import linkFiber from './routers/linkFiber';
1010
// timeJumpInitialization (actually uses the function timeJumpInitiation but is labeled here as linkFiberInitialization, returns a function) returns a function that sets jumping to false and handles timetravel feature
1111
import timeJumpInitialization from './controllers/timeJump';
12-
import { Snapshot, Status, MsgData } from './types/backendTypes';
12+
import { initUserEventCapture } from './controllers/userEventCapture';
13+
import { Status, MsgData } from './types/backendTypes';
1314
import routes from './models/routes';
1415

1516
// -------------------------INITIALIZE MODE--------------------------
@@ -31,6 +32,8 @@ const timeJump = timeJumpInitialization(mode);
3132
* 3. Send a snapshot of ReactFiber Tree to frontend/Chrome Extension
3233
*/
3334
linkFiberInit();
35+
// Capture user clicks so we can show "laser pointer" replay when time traveling
36+
initUserEventCapture();
3437

3538
// --------------INITIALIZE EVENT LISTENER FOR TIME TRAVEL----------------------
3639
/**

src/backend/routers/snapShot.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import { Snapshot, FiberRoot } from '../types/backendTypes';
1+
import { FiberRoot } from '../types/backendTypes';
22
import componentActionsRecord from '../models/masterState';
33
import routes from '../models/routes';
44
import createTree from '../controllers/createTree';
5+
import { getLastUserEvent } from '../controllers/userEventCapture';
56

67
// -------------------------UPDATE & SEND TREE SNAP SHOT------------------------
78
/**
8-
* This function creates a new `snapShot` fiber tree with the provided `fiberRoot`, then send the updated snapshot to front end.
9-
* This runs after every Fiber commit if mode is not jumping.
10-
* This
11-
* @param snapshot The current snapshot of the fiber tree
12-
* @param fiberRoot The `fiberRootNode`, which is the root node of the fiber tree is stored in the current property of the fiber root object which we can use to traverse the tree
9+
* Creates a new snapshot of the fiber tree and sends it to the front end.
10+
* Runs after every Fiber commit when not in jumping mode.
11+
* @param fiberRoot - The fiber root; the root node is in its `current` property.
1312
*/
14-
// updating tree depending on current mode on the panel (pause, etc)
1513
export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void {
1614
// This is the currently active root fiber(the mutable root of the tree)
1715
const { current } = fiberRoot;
@@ -22,6 +20,12 @@ export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void {
2220
const payload = createTree(current);
2321
// Save the current window url to route
2422
payload.route = routes.addRoute(window.location.href);
23+
// Attach last user click so the extension can show "laser pointer" replay when time traveling
24+
const lastEvent = getLastUserEvent();
25+
if (lastEvent) {
26+
// eslint-disable-next-line no-param-reassign -- attaching replay metadata to snapshot payload
27+
(payload as { lastUserEvent?: ReturnType<typeof getLastUserEvent> }).lastUserEvent = lastEvent;
28+
}
2529
// method safely enables cross-origin communication between Window objects;
2630
// e.g., between a page and a pop-up that it spawned, or between a page
2731
// and an iframe embedded within it.

src/extension/background.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,12 @@ chrome.runtime.onConnect.addListener(async (port) => {
512512
tabsObj[tabId].currBranch = 1; // reset currBranch
513513
tabsObj[tabId].currLocation = tabsObj[tabId].hierarchy; // reset currLocation
514514

515+
// Hide click-replay visualization since snapshots were cleared
516+
try {
517+
chrome.tabs.sendMessage(tabId, { action: 'hideClickReplay' });
518+
} catch (err) {
519+
// Tab may be closed or content script not loaded
520+
}
515521
return true; // return true so that port remains open
516522

517523
case 'setPause': // Pause = lock on tab
@@ -733,6 +739,12 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
733739
);
734740
}
735741

742+
// User action created new snapshot; hide click-replay visualization
743+
try {
744+
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
745+
} catch (err) {
746+
/* tab may be closed */
747+
}
736748
break;
737749
}
738750

@@ -756,15 +768,29 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
756768

757769
if (isDuplicateSnapshot(previousSnap, incomingSnap)) {
758770
console.warn('Duplicate snapshot detected, skipping');
771+
// Still hide pointer - user interaction triggered a snapshot even if we skipped it
772+
try {
773+
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
774+
} catch (err) {
775+
/* tab may be closed */
776+
}
759777
break;
760778
}
761779

762780
// Or if it is a snapShot after a jump, we don't record it.
781+
let didAddSnapshot = false;
763782
if (reloaded[tabId]) {
764783
// don't add anything to snapshot storage if tab is reloaded for the initial snapshot
765784
reloaded[tabId] = false;
785+
// Still hide pointer - snapshot after jump, user has moved on
786+
try {
787+
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
788+
} catch (err) {
789+
/* tab may be closed */
790+
}
766791
} else {
767792
tabsObj[tabId].snapshots.push(request.payload);
793+
didAddSnapshot = true;
768794
// INVOKING buildHierarchy FIGURE OUT WHAT TO PASS IN
769795
if (!tabsObj[tabId][index]) {
770796
// check if accessibility recording has been toggled on
@@ -799,6 +825,15 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
799825
} else {
800826
console.warn('No active ports to send snapshots to.');
801827
}
828+
829+
// User action created new snapshot; hide click-replay visualization
830+
if (didAddSnapshot) {
831+
try {
832+
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
833+
} catch (err) {
834+
/* tab may be closed */
835+
}
836+
}
802837
}
803838
default:
804839
break;

src/extension/contentScript.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
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';
43
import { onTTFB, onLCP, onFID, onFCP, onCLS, onINP } from 'web-vitals';
54

65
const 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.
144267
chrome.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

Comments
 (0)