diff --git a/.gitignore b/.gitignore index aa3509cac..e99728f75 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ src/extension/build.pem bower_components sandboxes/manual-tests/NextJS/.next .vscode +.cursor package-lock.json yarn.lock docs/**/* diff --git a/src/app/containers/MainContainer.tsx b/src/app/containers/MainContainer.tsx index 01028fd5d..0aaea8aab 100644 --- a/src/app/containers/MainContainer.tsx +++ b/src/app/containers/MainContainer.tsx @@ -77,7 +77,8 @@ function MainContainer(): JSX.Element { break; } case 'sendSnapshots': { - dispatch(setTab(payload)); + // sourceTab is the tab that sent the snapshot; setTab expects tabId, not tabsObj + if (typeof sourceTab === 'number') dispatch(setTab(sourceTab)); dispatch(addNewSnapshots(payload)); break; } diff --git a/src/app/slices/mainSlice.ts b/src/app/slices/mainSlice.ts index 740e9b718..6f7748006 100644 --- a/src/app/slices/mainSlice.ts +++ b/src/app/slices/mainSlice.ts @@ -50,10 +50,14 @@ export const mainSlice = createSlice({ const currSnapshot = tabs[currentTab].snapshots[tabs[currentTab].currLocation.index]; // current snapshot const currAxSnapshot = tabs[currentTab].axSnapshots[tabs[currentTab].currLocation.index]; // current accessibility tree snapshot - tabs[currentTab].hierarchy.stateSnapshot = { ...currSnapshot }; // resets hierarchy to current snapshot + // Strip lastUserEvent so jumpToSnap (from changeSlider) won't re-show the pointer + const cleanSnapshot = { ...currSnapshot }; + delete (cleanSnapshot as { lastUserEvent?: unknown }).lastUserEvent; + + tabs[currentTab].hierarchy.stateSnapshot = { ...cleanSnapshot }; // resets hierarchy to current snapshot tabs[currentTab].hierarchy.axSnapshot = { ...currAxSnapshot }; // resets hierarchy to current accessibility tree snapshot tabs[currentTab].hierarchy.children = []; // resets hierarchy - tabs[currentTab].snapshots = [currSnapshot]; // resets snapshots to current snapshot + tabs[currentTab].snapshots = [cleanSnapshot]; // resets snapshots to current snapshot (no lastUserEvent) tabs[currentTab].axSnapshots = [currAxSnapshot]; // resets snapshots to current snapshot // resets currLocation to current snapshot @@ -200,19 +204,21 @@ export const mainSlice = createSlice({ const { port, currentTab, tabs } = state; const { hierarchy, snapshots } = tabs[currentTab] || {}; - // finds the name by the action.payload parsing through the hierarchy to send to background.js the current name in the jump action - const nameFromIndex = findName(action.payload, hierarchy); - // nameFromIndex is a number based on which jump button is pushed + const index = typeof action.payload === 'object' ? action.payload.index : action.payload; + + // finds the name by the index parsing through the hierarchy to send to background.js the current name in the jump action + const nameFromIndex = findName(index, hierarchy); + // Always pass full snapshot so laser pointer shows when snapshot has lastUserEvent port.postMessage({ action: 'jumpToSnap', - payload: snapshots[action.payload], - index: action.payload, + payload: snapshots[index], + index, name: nameFromIndex, tabId: currentTab, }); - tabs[currentTab].sliderIndex = action.payload; + tabs[currentTab].sliderIndex = index; }, setCurrentTabInApp: (state, action) => { diff --git a/src/backend/controllers/userEventCapture.ts b/src/backend/controllers/userEventCapture.ts new file mode 100644 index 000000000..873e7a132 --- /dev/null +++ b/src/backend/controllers/userEventCapture.ts @@ -0,0 +1,39 @@ +/** + * Captures the last user interaction (click) in the target page so we can + * attach it to snapshots and show a "laser pointer" replay when time traveling. + * Uses viewport coordinates (clientX, clientY) for the overlay. + */ + +export interface LastUserEvent { + type: 'click'; + x: number; + y: number; + timestamp: number; +} + +let lastUserEvent: LastUserEvent | null = null; + +function handleClick(e: MouseEvent): void { + lastUserEvent = { + type: 'click', + x: e.clientX, + y: e.clientY, + timestamp: Date.now(), + }; +} + +/** + * Returns the most recent user click event (viewport coordinates). + * Used when building a snapshot payload so we can show where the user clicked. + */ +export function getLastUserEvent(): LastUserEvent | null { + return lastUserEvent ? { ...lastUserEvent } : null; +} + +/** + * Attach document-level click listener. Call once when the backend initializes. + */ +export function initUserEventCapture(): void { + if (typeof document === 'undefined') return; + document.addEventListener('click', handleClick, true); +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 6f68440e8..3b2ff27c7 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -9,7 +9,8 @@ import 'regenerator-runtime/runtime'; import linkFiber from './routers/linkFiber'; // 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 import timeJumpInitialization from './controllers/timeJump'; -import { Snapshot, Status, MsgData } from './types/backendTypes'; +import { initUserEventCapture } from './controllers/userEventCapture'; +import { Status, MsgData } from './types/backendTypes'; import routes from './models/routes'; // -------------------------INITIALIZE MODE-------------------------- @@ -31,6 +32,8 @@ const timeJump = timeJumpInitialization(mode); * 3. Send a snapshot of ReactFiber Tree to frontend/Chrome Extension */ linkFiberInit(); +// Capture user clicks so we can show "laser pointer" replay when time traveling +initUserEventCapture(); // --------------INITIALIZE EVENT LISTENER FOR TIME TRAVEL---------------------- /** diff --git a/src/backend/routers/snapShot.ts b/src/backend/routers/snapShot.ts index 41a5cb7f7..b546bbb2f 100644 --- a/src/backend/routers/snapShot.ts +++ b/src/backend/routers/snapShot.ts @@ -1,17 +1,15 @@ -import { Snapshot, FiberRoot } from '../types/backendTypes'; +import { FiberRoot } from '../types/backendTypes'; import componentActionsRecord from '../models/masterState'; import routes from '../models/routes'; import createTree from '../controllers/createTree'; +import { getLastUserEvent } from '../controllers/userEventCapture'; // -------------------------UPDATE & SEND TREE SNAP SHOT------------------------ /** - * This function creates a new `snapShot` fiber tree with the provided `fiberRoot`, then send the updated snapshot to front end. - * This runs after every Fiber commit if mode is not jumping. - * This - * @param snapshot The current snapshot of the fiber tree - * @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 + * Creates a new snapshot of the fiber tree and sends it to the front end. + * Runs after every Fiber commit when not in jumping mode. + * @param fiberRoot - The fiber root; the root node is in its `current` property. */ -// updating tree depending on current mode on the panel (pause, etc) export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void { // This is the currently active root fiber(the mutable root of the tree) const { current } = fiberRoot; @@ -22,6 +20,12 @@ export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void { const payload = createTree(current); // Save the current window url to route payload.route = routes.addRoute(window.location.href); + // Attach last user click so the extension can show "laser pointer" replay when time traveling + const lastEvent = getLastUserEvent(); + if (lastEvent) { + // eslint-disable-next-line no-param-reassign -- attaching replay metadata to snapshot payload + (payload as { lastUserEvent?: ReturnType }).lastUserEvent = lastEvent; + } // method safely enables cross-origin communication between Window objects; // e.g., between a page and a pop-up that it spawned, or between a page // and an iframe embedded within it. diff --git a/src/extension/background.js b/src/extension/background.js index 42bba411b..3de167ab6 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -512,6 +512,12 @@ chrome.runtime.onConnect.addListener(async (port) => { tabsObj[tabId].currBranch = 1; // reset currBranch tabsObj[tabId].currLocation = tabsObj[tabId].hierarchy; // reset currLocation + // Hide click-replay visualization since snapshots were cleared + try { + chrome.tabs.sendMessage(tabId, { action: 'hideClickReplay' }); + } catch (err) { + // Tab may be closed or content script not loaded + } return true; // return true so that port remains open case 'setPause': // Pause = lock on tab @@ -733,6 +739,12 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => { ); } + // User action created new snapshot; hide click-replay visualization + try { + chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' }); + } catch (err) { + /* tab may be closed */ + } break; } @@ -756,15 +768,29 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => { if (isDuplicateSnapshot(previousSnap, incomingSnap)) { console.warn('Duplicate snapshot detected, skipping'); + // Still hide pointer - user interaction triggered a snapshot even if we skipped it + try { + chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' }); + } catch (err) { + /* tab may be closed */ + } break; } // Or if it is a snapShot after a jump, we don't record it. + let didAddSnapshot = false; if (reloaded[tabId]) { // don't add anything to snapshot storage if tab is reloaded for the initial snapshot reloaded[tabId] = false; + // Still hide pointer - snapshot after jump, user has moved on + try { + chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' }); + } catch (err) { + /* tab may be closed */ + } } else { tabsObj[tabId].snapshots.push(request.payload); + didAddSnapshot = true; // INVOKING buildHierarchy FIGURE OUT WHAT TO PASS IN if (!tabsObj[tabId][index]) { // check if accessibility recording has been toggled on @@ -799,6 +825,15 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => { } else { console.warn('No active ports to send snapshots to.'); } + + // User action created new snapshot; hide click-replay visualization + if (didAddSnapshot) { + try { + chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' }); + } catch (err) { + /* tab may be closed */ + } + } } default: break; diff --git a/src/extension/contentScript.ts b/src/extension/contentScript.ts index 76a992750..36479584c 100644 --- a/src/extension/contentScript.ts +++ b/src/extension/contentScript.ts @@ -1,6 +1,5 @@ // Web vital metrics calculated by 'web-vitals' npm package to be displayed // in Web Metrics tab of Reactime app. -import { current } from '@reduxjs/toolkit'; import { onTTFB, onLCP, onFID, onFCP, onCLS, onINP } from 'web-vitals'; const MAX_RECONNECT_ATTEMPTS = 5; @@ -139,6 +138,130 @@ window.addEventListener('message', (msg) => { } }); +// User input visualization: show click position when time traveling (see docs/USER_INPUT_VISUALIZATION_IMPLEMENTATION.md) +const REACTIME_POINTER_OVERLAY_ID = 'reactime-pointer-overlay'; +const REACTIME_POINTER_STYLES_ID = 'reactime-pointer-styles'; +const REACTIME_POINTER_VISIBLE_CLASS = 'reactime-pointer-visible'; + +/** Cached refs to avoid repeated DOM lookups after first use */ +let pointerOverlayRef: HTMLElement | null = null; +let pointerDotRef: HTMLElement | null = null; +let pointerRippleRef: HTMLElement | null = null; + +const REACTIME_POINTER_STYLES = ` + #${REACTIME_POINTER_OVERLAY_ID} { + position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; + } + #${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-dot { + position: fixed; width: 22px; height: 22px; border-radius: 50%; + background: #0d9488; border: 3px solid #fff; + box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); + transform: translate(-50%, -50%); + } + #${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-ripple { + position: fixed; width: 22px; height: 22px; border-radius: 50%; + border: 3px solid #14b8a6; transform: translate(-50%, -50%); opacity: 0; + } + #${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-dot { + animation: reactime-dot-pulse 2s ease-in-out; animation-iteration-count: infinite; + } + #${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-ripple { + animation: reactime-ripple 1.2s ease-out; animation-iteration-count: infinite; + } + @keyframes reactime-dot-pulse { + 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); } + 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); } + 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); } + } + @keyframes reactime-ripple { + 0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.7; } + 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } + } + @media (prefers-reduced-motion: reduce) { + #${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-dot { + animation: reactime-dot-in 0.25s ease-out; + } + #${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-ripple { + animation: none; opacity: 0; + } + } + @keyframes reactime-dot-in { + from { transform: translate(-50%, -50%) scale(0); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } + } +`; + +/** + * Returns the pointer overlay element, creating it (and injecting styles) only on first use. + * Reuses cached refs to avoid repeated DOM lookups. + */ +function getOrCreatePointerOverlay(): HTMLElement { + if (pointerOverlayRef) return pointerOverlayRef; + + if (!document.getElementById(REACTIME_POINTER_STYLES_ID)) { + const style = document.createElement('style'); + style.id = REACTIME_POINTER_STYLES_ID; + style.textContent = REACTIME_POINTER_STYLES; + (document.head || document.documentElement).appendChild(style); + } + + const overlay = document.createElement('div'); + overlay.id = REACTIME_POINTER_OVERLAY_ID; + overlay.setAttribute('aria-hidden', 'true'); + const ripple = document.createElement('div'); + ripple.className = 'reactime-pointer-ripple'; + const dot = document.createElement('div'); + dot.className = 'reactime-pointer-dot'; + overlay.appendChild(ripple); + overlay.appendChild(dot); + overlay.style.display = 'none'; + (document.body || document.documentElement).appendChild(overlay); + + pointerOverlayRef = overlay; + pointerDotRef = dot; + pointerRippleRef = ripple; + return overlay; +} + +/** Payload shape we use for click replay (snapshot may include lastUserEvent from backend). */ +interface ClickReplayPayload { + lastUserEvent?: { x: number; y: number } | null; +} + +/** + * Shows or hides the click-replay pointer on the page based on snapshot payload. + * Uses cached overlay/dot/ripple refs after first run to avoid repeated DOM queries. + */ +function updateClickReplayPointer(payload: ClickReplayPayload | undefined): void { + const overlay = getOrCreatePointerOverlay(); + const dot = pointerDotRef; + const ripple = pointerRippleRef; + if (!dot) return; + + const event = payload?.lastUserEvent; + const hasValidEvent = + event != null && typeof event.x === 'number' && typeof event.y === 'number'; + + if (hasValidEvent) { + const left = `${event.x}px`; + const top = `${event.y}px`; + dot.style.left = left; + dot.style.top = top; + if (ripple) { + ripple.style.left = left; + ripple.style.top = top; + } + overlay.style.display = ''; + overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS); + requestAnimationFrame(() => { + overlay.classList.add(REACTIME_POINTER_VISIBLE_CLASS); + }); + } else { + overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS); + overlay.style.display = 'none'; + } +} + // FROM BACKGROUND TO CONTENT SCRIPT // Listening for messages from the UI of the Reactime extension. chrome.runtime.onMessage.addListener((request) => { @@ -151,12 +274,16 @@ chrome.runtime.onMessage.addListener((request) => { } // this is only listening for Jump toSnap if (action === 'jumpToSnap') { + updateClickReplayPointer(request.payload); chrome.runtime.sendMessage(request); // After the jumpToSnap action has been sent back to background js, // it will send the same action to backend files (index.ts) for it execute the jump feature // '*' == target window origin required for event to be dispatched, '*' = no preference window.postMessage(request, '*'); } + if (action === 'hideClickReplay') { + updateClickReplayPointer(undefined); + } if (action === 'portDisconnect' && !currentPort && !isAttemptingReconnect) { console.log('Received disconnect message, initiating reconnection'); // When we receive a port disconnection message, relay it to the window