Path: blob/main/core/injected-scripts/interactReplayer.ts
1028 views
// NOTE: do not use node dependencies12import type { IScrollRecord } from '@secret-agent/core/models/ScrollEventsTable';3import type { IMouseEventRecord } from '@secret-agent/core/models/MouseEventsTable';45declare global {6interface Window {7selfFrameIdPath: string;8}9}1011interface IFrontendMouseEvent extends Omit<IMouseEventRecord, 'commandId' | 'timestamp' | 'event'> {12frameIdPath: string;13viewportWidth: number;14viewportHeight: number;15}16interface IFrontendScrollRecord extends IScrollRecord {17frameIdPath: string;18}19let maxHighlightTop = -1;20let minHighlightTop = 10e3;21let replayNode: HTMLElement;22let replayShadow: ShadowRoot;23let lastHighlightNodes: number[] = [];2425window.replayInteractions = function replayInteractions(resultNodeIds, mouseEvent, scrollEvent) {26highlightNodes(resultNodeIds);27updateMouse(mouseEvent);28updateScroll(scrollEvent);29};3031const events = {32scroll: updateScroll,33mouse: updateMouse,34highlight: highlightNodes,35'clear-mouse': clearMouse,36'clear-highlights': clearHighlights,37};3839window.addEventListener('message', ev => {40if (!ev.data.action) return;41const { action, event } = ev.data;42const handler = events[action];43if (handler) {44handler(event);45if (action.startsWith('clear-')) {46for (const other of document.querySelectorAll('iframe,frame')) {47postToFrame(other, { action });48}49}50}51});5253function postToFrame(node: Node, data: any) {54const contentWindow = (node as HTMLIFrameElement).contentWindow;55if (contentWindow) contentWindow.postMessage(data, '*');56}5758function debugLog(message: string, ...args: any[]) {59if (window.debugToConsole) {60// eslint-disable-next-line prefer-rest-params,no-console61console.log(...arguments);62}63window.debugLogs.push({ message, args });64}6566function delegateInteractToSubframe(event: { frameIdPath: string }, action: string) {67if (!event?.frameIdPath) {68debugLog('Delegate requested on event without frameIdPath', event, action);69return;70}71const childPath = event.frameIdPath72.replace(window.selfFrameIdPath, '')73.split('_')74.filter(Boolean)75.map(Number);7677const childId = childPath.shift();7879const frame = window.getNodeById(childId) as HTMLIFrameElement;8081const allFrames = document.querySelectorAll('iframe,frame');82for (const other of allFrames) {83if (other !== frame) postToFrame(other, { action: `clear-${action}` });84}8586if (!frame?.contentWindow) {87debugLog('Interaction frame?.contentWindow not found', frame);88return;89}90frame.contentWindow.postMessage({ event, action }, '*');91}9293const highlightElements: HTMLElement[] = [];9495let showMoreUp: HTMLElement;96let showMoreDown: HTMLElement;97function checkOverflows() {98createReplayItems();99if (maxHighlightTop > window.innerHeight + window.scrollY) {100replayShadow.appendChild(showMoreDown);101} else {102showMoreDown.remove();103}104105if (minHighlightTop < window.scrollY) {106replayShadow.appendChild(showMoreUp);107} else {108showMoreUp.remove();109}110}111112function clearHighlights() {113lastHighlightNodes = [];114highlightElements.forEach(x => x.remove());115}116117function highlightNodes(nodes: { frameIdPath: string; nodeIds: number[] }) {118if (nodes === undefined) return;119if (nodes && nodes?.frameIdPath !== window.selfFrameIdPath) {120clearHighlights();121// delegate to subframe122delegateInteractToSubframe(nodes, 'highlight');123return;124}125126createReplayItems();127const nodeIds = nodes?.nodeIds;128lastHighlightNodes = nodeIds;129const length = nodeIds ? nodeIds.length : 0;130try {131minHighlightTop = 10e3;132maxHighlightTop = -1;133for (let i = 0; i < length; i += 1) {134const node = window.getNodeById(nodeIds[i]);135let hoverNode = highlightElements[i];136if (!hoverNode) {137hoverNode = document.createElement('sa-highlight');138highlightElements.push(hoverNode);139}140if (!node) {141hoverNode.remove();142continue;143}144const element = node.nodeType === node.TEXT_NODE ? node.parentElement : (node as Element);145const bounds = element.getBoundingClientRect();146bounds.x += window.scrollX;147bounds.y += window.scrollY;148hoverNode.style.width = `${bounds.width}px`;149hoverNode.style.height = `${bounds.height}px`;150hoverNode.style.top = `${bounds.top - 5}px`;151hoverNode.style.left = `${bounds.left - 5}px`;152153if (bounds.y > maxHighlightTop) maxHighlightTop = bounds.y;154if (bounds.y + bounds.height < minHighlightTop) minHighlightTop = bounds.y + bounds.height;155replayShadow.appendChild(hoverNode);156}157158checkOverflows();159for (let i = length; i < highlightElements.length; i += 1) {160highlightElements[i].remove();161}162} catch (err) {163// eslint-disable-next-line no-console164console.error(err);165}166}167168/////// MOUSE EVENTS ///////////////////////////////////////////////////////////////////////////////////////////////////169170let lastMouseEvent: IFrontendMouseEvent;171let mouse: HTMLElement;172173const elementAbsolutes = new Map<HTMLElement, { top: number; left: number }>();174const elementDisplayCache = new Map<HTMLElement, string>();175const offsetsAtPageY = new Map<number, { pageOffset: number; elementOffset: number }>();176const offsetBlock = 100;177178function clearMouse() {179lastMouseEvent = null;180if (mouse) mouse.style.display = 'none';181}182183function updateMouse(mouseEvent: IFrontendMouseEvent) {184if (!mouseEvent) return;185if (mouseEvent.frameIdPath !== window.selfFrameIdPath) {186clearMouse();187delegateInteractToSubframe(mouseEvent, 'mouse');188return;189}190191createReplayItems();192193lastMouseEvent = mouseEvent;194if (mouseEvent.pageX !== undefined) {195const targetNode = window.getNodeById(mouseEvent.targetNodeId) as HTMLElement;196197let pageY = mouseEvent.pageY;198199if (mouseEvent.targetNodeId && targetNode) {200const pageOffsetsYKey = pageY - (pageY % offsetBlock);201// try last two offset zones202const pageOffsetsAtHeight =203offsetsAtPageY.get(pageOffsetsYKey) ?? offsetsAtPageY.get(pageOffsetsYKey - offsetBlock);204// if there's a page translation we've found that's closer than this one, use it205if (206pageOffsetsAtHeight &&207Math.abs(pageOffsetsAtHeight.elementOffset) < Math.abs(mouseEvent.offsetY)208) {209pageY = mouseEvent.pageY + pageOffsetsAtHeight.pageOffset;210} else {211const { top } = getElementAbsolutePosition(targetNode);212pageY = Math.round(mouseEvent.offsetY + top);213const offsetAtYHeightEntry = offsetsAtPageY.get(pageOffsetsYKey);214if (215!offsetAtYHeightEntry ||216Math.abs(offsetAtYHeightEntry.elementOffset) > Math.abs(mouseEvent.offsetY)217) {218offsetsAtPageY.set(pageOffsetsYKey, {219elementOffset: mouseEvent.offsetY,220pageOffset: pageY - mouseEvent.pageY,221});222}223}224}225226mouse.style.left = `${mouseEvent.pageX}px`;227mouse.style.top = `${pageY}px`;228mouse.style.display = 'block';229}230if (mouseEvent.buttons !== undefined) {231for (let i = 0; i < 5; i += 1) {232mouse.classList.toggle(`button-${i}`, (mouseEvent.buttons & (1 << i)) !== 0);233}234}235}236237function getElementAbsolutePosition(element: HTMLElement) {238const offsetElement = getOffsetElement(element);239if (!elementAbsolutes.has(offsetElement)) {240const rect = offsetElement.getBoundingClientRect();241const absoluteX = Math.round(rect.left + window.scrollX);242const absoluteY = Math.round(rect.top + window.scrollY);243elementAbsolutes.set(offsetElement, { top: absoluteY, left: absoluteX });244}245return elementAbsolutes.get(offsetElement);246}247248function getOffsetElement(element: HTMLElement) {249while (element.tagName !== 'BODY') {250if (!elementDisplayCache.has(element)) {251elementDisplayCache.set(element, getComputedStyle(element).display);252}253const display = elementDisplayCache.get(element);254if (display === 'inline') {255const offsetParent = element.parentElement as HTMLElement;256if (!offsetParent) break;257element = offsetParent;258} else {259break;260}261}262return element;263}264265function updateScroll(scrollEvent: IFrontendScrollRecord) {266if (!scrollEvent) return;267if (scrollEvent.frameIdPath !== window.selfFrameIdPath) {268return delegateInteractToSubframe(scrollEvent, 'scroll');269}270window.scroll({271behavior: 'auto',272top: scrollEvent.scrollY,273left: scrollEvent.scrollX,274});275}276277/////// BUILD UI ELEMENTS //////////////////////////////////////////////////////////////////////////////////////////////278279let isInitialized = false;280function createReplayItems() {281if (replayNode && !replayNode.isConnected) {282document.body.appendChild(replayNode);283}284if (isInitialized) return;285isInitialized = true;286287replayNode = document.createElement('sa-replay');288replayNode.style.zIndex = '10000000';289290replayShadow = replayNode.attachShadow({ mode: 'closed' });291292showMoreUp = document.createElement('sa-overflow');293showMoreUp.style.top = '0';294showMoreUp.innerHTML = `<sa-overflow-bar> </sa-overflow-bar>`;295296showMoreDown = document.createElement('sa-overflow');297showMoreDown.style.bottom = '0';298showMoreDown.innerHTML = `<sa-overflow-bar> </sa-overflow-bar>`;299300const styleElement = document.createElement('style');301styleElement.textContent = `302sa-overflow-bar {303width: 500px;304background-color:#3498db;305margin:0 auto;306height: 100%;307box-shadow: 3px 0 0 0 #3498db;308display:block;309}310311sa-overflow {312z-index: 2147483647;313display:block;314width:100%;315height:8px;316position:fixed;317pointer-events: none;318}319320sa-highlight {321z-index: 2147483647;322position:absolute;323box-shadow: 1px 1px 3px 0 #3498db;324border-radius:3px;325border:1px solid #3498db;326padding:5px;327pointer-events: none;328}329330sa-mouse-pointer {331pointer-events: none;332position: absolute;333top: 0;334z-index: 2147483647;335left: 0;336width: 20px;337height: 20px;338background: rgba(0,0,0,.4);339border: 1px solid white;340border-radius: 10px;341margin: -10px 0 0 -10px;342padding: 0;343transition: background .2s, border-radius .2s, border-color .2s;344}345sa-mouse-pointer.button-1 {346transition: none;347background: rgba(0,0,0,0.9);348}349sa-mouse-pointer.button-2 {350transition: none;351border-color: rgba(0,0,255,0.9);352}353sa-mouse-pointer.button-3 {354transition: none;355border-radius: 4px;356}357sa-mouse-pointer.button-4 {358transition: none;359border-color: rgba(255,0,0,0.9);360}361sa-mouse-pointer.button-5 {362transition: none;363border-color: rgba(0,255,0,0.9);364}365`;366replayShadow.appendChild(styleElement);367368mouse = document.createElement('sa-mouse-pointer');369mouse.style.display = 'none';370replayShadow.appendChild(mouse);371372function cancelEvent(e: Event) {373e.preventDefault();374e.stopPropagation();375return false;376}377378document.addEventListener('click', cancelEvent, true);379document.addEventListener('submit', cancelEvent, true);380document.addEventListener('scroll', () => checkOverflows());381window.addEventListener('resize', () => {382if (lastHighlightNodes)383highlightNodes({ frameIdPath: window.selfFrameIdPath, nodeIds: lastHighlightNodes });384if (lastMouseEvent) updateMouse(lastMouseEvent);385});386document.body.appendChild(replayNode);387}388389390