Skip to content

Commit 53d6f24

Browse files
Add user input
1 parent af33d2e commit 53d6f24

4 files changed

Lines changed: 156 additions & 1 deletion

File tree

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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 { initUserEventCapture } from './controllers/userEventCapture';
1213
import { Snapshot, Status, MsgData } from './types/backendTypes';
1314
import routes from './models/routes';
1415

@@ -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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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
/**
@@ -22,6 +23,12 @@ export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void {
2223
const payload = createTree(current);
2324
// Save the current window url to route
2425
payload.route = routes.addRoute(window.location.href);
26+
// Attach last user click so the extension can show "laser pointer" replay when time traveling
27+
const lastEvent = getLastUserEvent();
28+
if (lastEvent) {
29+
// eslint-disable-next-line no-param-reassign -- attaching replay metadata to snapshot payload
30+
(payload as { lastUserEvent?: ReturnType<typeof getLastUserEvent> }).lastUserEvent = lastEvent;
31+
}
2532
// method safely enables cross-origin communication between Window objects;
2633
// e.g., between a page and a pop-up that it spawned, or between a page
2734
// and an iframe embedded within it.

src/extension/contentScript.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,111 @@ window.addEventListener('message', (msg) => {
139139
}
140140
});
141141

142+
// -------------- USER INPUT VISUALIZATION (CLICK REPLAY POINTER) --------------
143+
const REACTIME_POINTER_OVERLAY_ID = 'reactime-pointer-overlay';
144+
const REACTIME_POINTER_STYLES_ID = 'reactime-pointer-styles';
145+
146+
function getOrCreatePointerOverlay() {
147+
let overlay = document.getElementById(REACTIME_POINTER_OVERLAY_ID);
148+
if (!overlay) {
149+
// Inject styles once – pointer designed to draw attention (larger, ripple, glow)
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-in 0.25s ease-out;
181+
}
182+
#${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-ripple {
183+
animation: reactime-ripple 0.8s ease-out 1;
184+
}
185+
@keyframes reactime-dot-in {
186+
from { transform: translate(-50%, -50%) scale(0); opacity: 0; }
187+
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
188+
}
189+
@keyframes reactime-ripple {
190+
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.8; }
191+
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
192+
}
193+
@media (prefers-reduced-motion: reduce) {
194+
#${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-dot,
195+
#${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-ripple {
196+
animation: none;
197+
}
198+
#${REACTIME_POINTER_OVERLAY_ID}.reactime-pointer-visible .reactime-pointer-ripple {
199+
opacity: 0;
200+
}
201+
}
202+
`;
203+
(document.head || document.documentElement).appendChild(style);
204+
}
205+
overlay = document.createElement('div');
206+
overlay.id = REACTIME_POINTER_OVERLAY_ID;
207+
overlay.setAttribute('aria-hidden', 'true');
208+
const ripple = document.createElement('div');
209+
ripple.className = 'reactime-pointer-ripple';
210+
const dot = document.createElement('div');
211+
dot.className = 'reactime-pointer-dot';
212+
overlay.appendChild(ripple);
213+
overlay.appendChild(dot);
214+
overlay.style.display = 'none';
215+
(document.body || document.documentElement).appendChild(overlay);
216+
}
217+
return overlay;
218+
}
219+
220+
function updateClickReplayPointer(payload) {
221+
const event = payload?.lastUserEvent;
222+
const overlay = getOrCreatePointerOverlay();
223+
const dot = overlay.querySelector('.reactime-pointer-dot');
224+
const ripple = overlay.querySelector('.reactime-pointer-ripple');
225+
if (!dot || !(dot instanceof HTMLElement)) return;
226+
if (event && typeof event.x === 'number' && typeof event.y === 'number') {
227+
const left = `${event.x}px`;
228+
const top = `${event.y}px`;
229+
dot.style.left = left;
230+
dot.style.top = top;
231+
if (ripple && ripple instanceof HTMLElement) {
232+
ripple.style.left = left;
233+
ripple.style.top = top;
234+
}
235+
overlay.style.display = '';
236+
// Remove then re-add visible class so ripple animation plays every time we jump
237+
overlay.classList.remove('reactime-pointer-visible');
238+
requestAnimationFrame(() => {
239+
overlay.classList.add('reactime-pointer-visible');
240+
});
241+
} else {
242+
overlay.classList.remove('reactime-pointer-visible');
243+
overlay.style.display = 'none';
244+
}
245+
}
246+
142247
// FROM BACKGROUND TO CONTENT SCRIPT
143248
// Listening for messages from the UI of the Reactime extension.
144249
chrome.runtime.onMessage.addListener((request) => {
@@ -151,6 +256,7 @@ chrome.runtime.onMessage.addListener((request) => {
151256
}
152257
// this is only listening for Jump toSnap
153258
if (action === 'jumpToSnap') {
259+
updateClickReplayPointer(request.payload);
154260
chrome.runtime.sendMessage(request);
155261
// After the jumpToSnap action has been sent back to background js,
156262
// it will send the same action to backend files (index.ts) for it execute the jump feature

0 commit comments

Comments
 (0)