import type {Replayer} from 'rrweb'; const DEFAULT_HIGHLIGHT_COLOR = 'rgba(168, 196, 236, 0.75)'; const highlightsByNodeId: Map = new Map(); interface AddHighlightParams { nodeId: number; replayer: Replayer; annotation?: string; color?: string; } interface RemoveHighlightParams { nodeId: number; replayer: Replayer; } interface ClearAllHighlightsParams { replayer: Replayer; } export function clearAllHighlights({replayer}: ClearAllHighlightsParams) { for (const nodeId of highlightsByNodeId.keys()) { removeHighlightedNode({replayer, nodeId}); } } /** * Remove the canvas that has the highlight for a node. * * XXX: This is potentially not good if we have a lot of highlights, as we * are creating a new canvas PER highlight. */ export function removeHighlightedNode({replayer, nodeId}: RemoveHighlightParams) { if (!highlightsByNodeId.has(nodeId)) { return false; } const highlightObj = highlightsByNodeId.get(nodeId); if (!highlightObj || !replayer.wrapper.contains(highlightObj.canvas)) { return false; } replayer.wrapper.removeChild(highlightObj.canvas); highlightsByNodeId.delete(nodeId); return true; } /** * Attempt to highlight the node inside of a replay recording */ export function highlightNode({ replayer, nodeId, annotation = '', color, }: AddHighlightParams) { // @ts-expect-error mouseTail is private const {mouseTail, wrapper} = replayer; const mirror = replayer.getMirror(); const node = mirror.getNode(nodeId); // TODO(replays): There is some sort of race condition here when you "rewind" a replay, // mirror will be empty and highlight does not get added because node is null if (!node || !replayer.iframe.contentDocument?.body?.contains(node)) { return null; } // @ts-ignore This builds locally, but fails in CI -- ignoring for now const {top, left, width, height} = node.getBoundingClientRect(); const highlightColor = color ?? DEFAULT_HIGHLIGHT_COLOR; // Clone the mouseTail canvas as it has the dimensions and position that we // want on top of the replay. We may need to revisit this strategy as we // create a new canvas for every highlight. See additional notes in // removeHighlight() method. const canvas = mouseTail.cloneNode(); const ctx = canvas.getContext('2d'); if (!ctx) { return null; } // TODO(replays): Does not account for scrolling (should we attempt to keep highlight visible, or does it disappear) // Draw a rectangle to highlight element ctx.fillStyle = highlightColor; ctx.fillRect(left, top, width, height); // Draw a dashed border around highlight ctx.beginPath(); ctx.setLineDash([5, 5]); ctx.moveTo(left, top); ctx.lineTo(left + width, top); ctx.lineTo(left + width, top + height); ctx.lineTo(left, top + height); ctx.closePath(); ctx.stroke(); ctx.font = '24px Rubik'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; const textWidth = ctx.measureText(annotation).width; // Draw rect around text ctx.fillStyle = 'rgba(30, 30, 30, 0.75)'; ctx.fillRect(left + width - textWidth, top + height - 30, textWidth, 30); // Draw text ctx.fillStyle = 'white'; ctx.fillText(annotation, left + width, top + height); highlightsByNodeId.set(nodeId, { canvas, }); wrapper.insertBefore(canvas, mouseTail); return { canvas, }; }