Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/injected-scripts/interactReplayer.ts
1028 views
1
// NOTE: do not use node dependencies
2
3
import type { IScrollRecord } from '@secret-agent/core/models/ScrollEventsTable';
4
import type { IMouseEventRecord } from '@secret-agent/core/models/MouseEventsTable';
5
6
declare global {
7
interface Window {
8
selfFrameIdPath: string;
9
}
10
}
11
12
interface IFrontendMouseEvent extends Omit<IMouseEventRecord, 'commandId' | 'timestamp' | 'event'> {
13
frameIdPath: string;
14
viewportWidth: number;
15
viewportHeight: number;
16
}
17
interface IFrontendScrollRecord extends IScrollRecord {
18
frameIdPath: string;
19
}
20
let maxHighlightTop = -1;
21
let minHighlightTop = 10e3;
22
let replayNode: HTMLElement;
23
let replayShadow: ShadowRoot;
24
let lastHighlightNodes: number[] = [];
25
26
window.replayInteractions = function replayInteractions(resultNodeIds, mouseEvent, scrollEvent) {
27
highlightNodes(resultNodeIds);
28
updateMouse(mouseEvent);
29
updateScroll(scrollEvent);
30
};
31
32
const events = {
33
scroll: updateScroll,
34
mouse: updateMouse,
35
highlight: highlightNodes,
36
'clear-mouse': clearMouse,
37
'clear-highlights': clearHighlights,
38
};
39
40
window.addEventListener('message', ev => {
41
if (!ev.data.action) return;
42
const { action, event } = ev.data;
43
const handler = events[action];
44
if (handler) {
45
handler(event);
46
if (action.startsWith('clear-')) {
47
for (const other of document.querySelectorAll('iframe,frame')) {
48
postToFrame(other, { action });
49
}
50
}
51
}
52
});
53
54
function postToFrame(node: Node, data: any) {
55
const contentWindow = (node as HTMLIFrameElement).contentWindow;
56
if (contentWindow) contentWindow.postMessage(data, '*');
57
}
58
59
function debugLog(message: string, ...args: any[]) {
60
if (window.debugToConsole) {
61
// eslint-disable-next-line prefer-rest-params,no-console
62
console.log(...arguments);
63
}
64
window.debugLogs.push({ message, args });
65
}
66
67
function delegateInteractToSubframe(event: { frameIdPath: string }, action: string) {
68
if (!event?.frameIdPath) {
69
debugLog('Delegate requested on event without frameIdPath', event, action);
70
return;
71
}
72
const childPath = event.frameIdPath
73
.replace(window.selfFrameIdPath, '')
74
.split('_')
75
.filter(Boolean)
76
.map(Number);
77
78
const childId = childPath.shift();
79
80
const frame = window.getNodeById(childId) as HTMLIFrameElement;
81
82
const allFrames = document.querySelectorAll('iframe,frame');
83
for (const other of allFrames) {
84
if (other !== frame) postToFrame(other, { action: `clear-${action}` });
85
}
86
87
if (!frame?.contentWindow) {
88
debugLog('Interaction frame?.contentWindow not found', frame);
89
return;
90
}
91
frame.contentWindow.postMessage({ event, action }, '*');
92
}
93
94
const highlightElements: HTMLElement[] = [];
95
96
let showMoreUp: HTMLElement;
97
let showMoreDown: HTMLElement;
98
function checkOverflows() {
99
createReplayItems();
100
if (maxHighlightTop > window.innerHeight + window.scrollY) {
101
replayShadow.appendChild(showMoreDown);
102
} else {
103
showMoreDown.remove();
104
}
105
106
if (minHighlightTop < window.scrollY) {
107
replayShadow.appendChild(showMoreUp);
108
} else {
109
showMoreUp.remove();
110
}
111
}
112
113
function clearHighlights() {
114
lastHighlightNodes = [];
115
highlightElements.forEach(x => x.remove());
116
}
117
118
function highlightNodes(nodes: { frameIdPath: string; nodeIds: number[] }) {
119
if (nodes === undefined) return;
120
if (nodes && nodes?.frameIdPath !== window.selfFrameIdPath) {
121
clearHighlights();
122
// delegate to subframe
123
delegateInteractToSubframe(nodes, 'highlight');
124
return;
125
}
126
127
createReplayItems();
128
const nodeIds = nodes?.nodeIds;
129
lastHighlightNodes = nodeIds;
130
const length = nodeIds ? nodeIds.length : 0;
131
try {
132
minHighlightTop = 10e3;
133
maxHighlightTop = -1;
134
for (let i = 0; i < length; i += 1) {
135
const node = window.getNodeById(nodeIds[i]);
136
let hoverNode = highlightElements[i];
137
if (!hoverNode) {
138
hoverNode = document.createElement('sa-highlight');
139
highlightElements.push(hoverNode);
140
}
141
if (!node) {
142
hoverNode.remove();
143
continue;
144
}
145
const element = node.nodeType === node.TEXT_NODE ? node.parentElement : (node as Element);
146
const bounds = element.getBoundingClientRect();
147
bounds.x += window.scrollX;
148
bounds.y += window.scrollY;
149
hoverNode.style.width = `${bounds.width}px`;
150
hoverNode.style.height = `${bounds.height}px`;
151
hoverNode.style.top = `${bounds.top - 5}px`;
152
hoverNode.style.left = `${bounds.left - 5}px`;
153
154
if (bounds.y > maxHighlightTop) maxHighlightTop = bounds.y;
155
if (bounds.y + bounds.height < minHighlightTop) minHighlightTop = bounds.y + bounds.height;
156
replayShadow.appendChild(hoverNode);
157
}
158
159
checkOverflows();
160
for (let i = length; i < highlightElements.length; i += 1) {
161
highlightElements[i].remove();
162
}
163
} catch (err) {
164
// eslint-disable-next-line no-console
165
console.error(err);
166
}
167
}
168
169
/////// MOUSE EVENTS ///////////////////////////////////////////////////////////////////////////////////////////////////
170
171
let lastMouseEvent: IFrontendMouseEvent;
172
let mouse: HTMLElement;
173
174
const elementAbsolutes = new Map<HTMLElement, { top: number; left: number }>();
175
const elementDisplayCache = new Map<HTMLElement, string>();
176
const offsetsAtPageY = new Map<number, { pageOffset: number; elementOffset: number }>();
177
const offsetBlock = 100;
178
179
function clearMouse() {
180
lastMouseEvent = null;
181
if (mouse) mouse.style.display = 'none';
182
}
183
184
function updateMouse(mouseEvent: IFrontendMouseEvent) {
185
if (!mouseEvent) return;
186
if (mouseEvent.frameIdPath !== window.selfFrameIdPath) {
187
clearMouse();
188
delegateInteractToSubframe(mouseEvent, 'mouse');
189
return;
190
}
191
192
createReplayItems();
193
194
lastMouseEvent = mouseEvent;
195
if (mouseEvent.pageX !== undefined) {
196
const targetNode = window.getNodeById(mouseEvent.targetNodeId) as HTMLElement;
197
198
let pageY = mouseEvent.pageY;
199
200
if (mouseEvent.targetNodeId && targetNode) {
201
const pageOffsetsYKey = pageY - (pageY % offsetBlock);
202
// try last two offset zones
203
const pageOffsetsAtHeight =
204
offsetsAtPageY.get(pageOffsetsYKey) ?? offsetsAtPageY.get(pageOffsetsYKey - offsetBlock);
205
// if there's a page translation we've found that's closer than this one, use it
206
if (
207
pageOffsetsAtHeight &&
208
Math.abs(pageOffsetsAtHeight.elementOffset) < Math.abs(mouseEvent.offsetY)
209
) {
210
pageY = mouseEvent.pageY + pageOffsetsAtHeight.pageOffset;
211
} else {
212
const { top } = getElementAbsolutePosition(targetNode);
213
pageY = Math.round(mouseEvent.offsetY + top);
214
const offsetAtYHeightEntry = offsetsAtPageY.get(pageOffsetsYKey);
215
if (
216
!offsetAtYHeightEntry ||
217
Math.abs(offsetAtYHeightEntry.elementOffset) > Math.abs(mouseEvent.offsetY)
218
) {
219
offsetsAtPageY.set(pageOffsetsYKey, {
220
elementOffset: mouseEvent.offsetY,
221
pageOffset: pageY - mouseEvent.pageY,
222
});
223
}
224
}
225
}
226
227
mouse.style.left = `${mouseEvent.pageX}px`;
228
mouse.style.top = `${pageY}px`;
229
mouse.style.display = 'block';
230
}
231
if (mouseEvent.buttons !== undefined) {
232
for (let i = 0; i < 5; i += 1) {
233
mouse.classList.toggle(`button-${i}`, (mouseEvent.buttons & (1 << i)) !== 0);
234
}
235
}
236
}
237
238
function getElementAbsolutePosition(element: HTMLElement) {
239
const offsetElement = getOffsetElement(element);
240
if (!elementAbsolutes.has(offsetElement)) {
241
const rect = offsetElement.getBoundingClientRect();
242
const absoluteX = Math.round(rect.left + window.scrollX);
243
const absoluteY = Math.round(rect.top + window.scrollY);
244
elementAbsolutes.set(offsetElement, { top: absoluteY, left: absoluteX });
245
}
246
return elementAbsolutes.get(offsetElement);
247
}
248
249
function getOffsetElement(element: HTMLElement) {
250
while (element.tagName !== 'BODY') {
251
if (!elementDisplayCache.has(element)) {
252
elementDisplayCache.set(element, getComputedStyle(element).display);
253
}
254
const display = elementDisplayCache.get(element);
255
if (display === 'inline') {
256
const offsetParent = element.parentElement as HTMLElement;
257
if (!offsetParent) break;
258
element = offsetParent;
259
} else {
260
break;
261
}
262
}
263
return element;
264
}
265
266
function updateScroll(scrollEvent: IFrontendScrollRecord) {
267
if (!scrollEvent) return;
268
if (scrollEvent.frameIdPath !== window.selfFrameIdPath) {
269
return delegateInteractToSubframe(scrollEvent, 'scroll');
270
}
271
window.scroll({
272
behavior: 'auto',
273
top: scrollEvent.scrollY,
274
left: scrollEvent.scrollX,
275
});
276
}
277
278
/////// BUILD UI ELEMENTS //////////////////////////////////////////////////////////////////////////////////////////////
279
280
let isInitialized = false;
281
function createReplayItems() {
282
if (replayNode && !replayNode.isConnected) {
283
document.body.appendChild(replayNode);
284
}
285
if (isInitialized) return;
286
isInitialized = true;
287
288
replayNode = document.createElement('sa-replay');
289
replayNode.style.zIndex = '10000000';
290
291
replayShadow = replayNode.attachShadow({ mode: 'closed' });
292
293
showMoreUp = document.createElement('sa-overflow');
294
showMoreUp.style.top = '0';
295
showMoreUp.innerHTML = `<sa-overflow-bar>&nbsp;</sa-overflow-bar>`;
296
297
showMoreDown = document.createElement('sa-overflow');
298
showMoreDown.style.bottom = '0';
299
showMoreDown.innerHTML = `<sa-overflow-bar>&nbsp;</sa-overflow-bar>`;
300
301
const styleElement = document.createElement('style');
302
styleElement.textContent = `
303
sa-overflow-bar {
304
width: 500px;
305
background-color:#3498db;
306
margin:0 auto;
307
height: 100%;
308
box-shadow: 3px 0 0 0 #3498db;
309
display:block;
310
}
311
312
sa-overflow {
313
z-index: 2147483647;
314
display:block;
315
width:100%;
316
height:8px;
317
position:fixed;
318
pointer-events: none;
319
}
320
321
sa-highlight {
322
z-index: 2147483647;
323
position:absolute;
324
box-shadow: 1px 1px 3px 0 #3498db;
325
border-radius:3px;
326
border:1px solid #3498db;
327
padding:5px;
328
pointer-events: none;
329
}
330
331
sa-mouse-pointer {
332
pointer-events: none;
333
position: absolute;
334
top: 0;
335
z-index: 2147483647;
336
left: 0;
337
width: 20px;
338
height: 20px;
339
background: rgba(0,0,0,.4);
340
border: 1px solid white;
341
border-radius: 10px;
342
margin: -10px 0 0 -10px;
343
padding: 0;
344
transition: background .2s, border-radius .2s, border-color .2s;
345
}
346
sa-mouse-pointer.button-1 {
347
transition: none;
348
background: rgba(0,0,0,0.9);
349
}
350
sa-mouse-pointer.button-2 {
351
transition: none;
352
border-color: rgba(0,0,255,0.9);
353
}
354
sa-mouse-pointer.button-3 {
355
transition: none;
356
border-radius: 4px;
357
}
358
sa-mouse-pointer.button-4 {
359
transition: none;
360
border-color: rgba(255,0,0,0.9);
361
}
362
sa-mouse-pointer.button-5 {
363
transition: none;
364
border-color: rgba(0,255,0,0.9);
365
}
366
`;
367
replayShadow.appendChild(styleElement);
368
369
mouse = document.createElement('sa-mouse-pointer');
370
mouse.style.display = 'none';
371
replayShadow.appendChild(mouse);
372
373
function cancelEvent(e: Event) {
374
e.preventDefault();
375
e.stopPropagation();
376
return false;
377
}
378
379
document.addEventListener('click', cancelEvent, true);
380
document.addEventListener('submit', cancelEvent, true);
381
document.addEventListener('scroll', () => checkOverflows());
382
window.addEventListener('resize', () => {
383
if (lastHighlightNodes)
384
highlightNodes({ frameIdPath: window.selfFrameIdPath, nodeIds: lastHighlightNodes });
385
if (lastMouseEvent) updateMouse(lastMouseEvent);
386
});
387
document.body.appendChild(replayNode);
388
}
389
390