highlightNode.tsx 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import type {Replayer} from 'rrweb';
  2. const DEFAULT_HIGHLIGHT_COLOR = 'rgba(168, 196, 236, 0.75)';
  3. const highlightsByNodeId: Map<number, {canvas: HTMLCanvasElement}> = new Map();
  4. interface AddHighlightParams {
  5. nodeId: number;
  6. replayer: Replayer;
  7. annotation?: string;
  8. color?: string;
  9. }
  10. interface RemoveHighlightParams {
  11. nodeId: number;
  12. replayer: Replayer;
  13. }
  14. interface ClearAllHighlightsParams {
  15. replayer: Replayer;
  16. }
  17. export function clearAllHighlights({replayer}: ClearAllHighlightsParams) {
  18. for (const nodeId of highlightsByNodeId.keys()) {
  19. removeHighlightedNode({replayer, nodeId});
  20. }
  21. }
  22. /**
  23. * Remove the canvas that has the highlight for a node.
  24. *
  25. * XXX: This is potentially not good if we have a lot of highlights, as we
  26. * are creating a new canvas PER highlight.
  27. */
  28. export function removeHighlightedNode({replayer, nodeId}: RemoveHighlightParams) {
  29. if (!highlightsByNodeId.has(nodeId)) {
  30. return false;
  31. }
  32. const highlightObj = highlightsByNodeId.get(nodeId);
  33. if (!highlightObj || !replayer.wrapper.contains(highlightObj.canvas)) {
  34. return false;
  35. }
  36. replayer.wrapper.removeChild(highlightObj.canvas);
  37. highlightsByNodeId.delete(nodeId);
  38. return true;
  39. }
  40. /**
  41. * Attempt to highlight the node inside of a replay recording
  42. */
  43. export function highlightNode({
  44. replayer,
  45. nodeId,
  46. annotation = '',
  47. color,
  48. }: AddHighlightParams) {
  49. // @ts-expect-error mouseTail is private
  50. const {mouseTail, wrapper} = replayer;
  51. const mirror = replayer.getMirror();
  52. const node = mirror.getNode(nodeId);
  53. // TODO(replays): There is some sort of race condition here when you "rewind" a replay,
  54. // mirror will be empty and highlight does not get added because node is null
  55. if (!node || !replayer.iframe.contentDocument?.body?.contains(node)) {
  56. return null;
  57. }
  58. // @ts-ignore This builds locally, but fails in CI -- ignoring for now
  59. const {top, left, width, height} = node.getBoundingClientRect();
  60. const highlightColor = color ?? DEFAULT_HIGHLIGHT_COLOR;
  61. // Clone the mouseTail canvas as it has the dimensions and position that we
  62. // want on top of the replay. We may need to revisit this strategy as we
  63. // create a new canvas for every highlight. See additional notes in
  64. // removeHighlight() method.
  65. const canvas = mouseTail.cloneNode();
  66. const ctx = canvas.getContext('2d');
  67. if (!ctx) {
  68. return null;
  69. }
  70. // TODO(replays): Does not account for scrolling (should we attempt to keep highlight visible, or does it disappear)
  71. // Draw a rectangle to highlight element
  72. ctx.fillStyle = highlightColor;
  73. ctx.fillRect(left, top, width, height);
  74. // Draw a dashed border around highlight
  75. ctx.beginPath();
  76. ctx.setLineDash([5, 5]);
  77. ctx.moveTo(left, top);
  78. ctx.lineTo(left + width, top);
  79. ctx.lineTo(left + width, top + height);
  80. ctx.lineTo(left, top + height);
  81. ctx.closePath();
  82. ctx.stroke();
  83. ctx.font = '24px Rubik';
  84. ctx.textAlign = 'right';
  85. ctx.textBaseline = 'bottom';
  86. const textWidth = ctx.measureText(annotation).width;
  87. // Draw rect around text
  88. ctx.fillStyle = 'rgba(30, 30, 30, 0.75)';
  89. ctx.fillRect(left + width - textWidth, top + height - 30, textWidth, 30);
  90. // Draw text
  91. ctx.fillStyle = 'white';
  92. ctx.fillText(annotation, left + width, top + height);
  93. highlightsByNodeId.set(nodeId, {
  94. canvas,
  95. });
  96. wrapper.insertBefore(canvas, mouseTail);
  97. return {
  98. canvas,
  99. };
  100. }