highlightNode.tsx 4.1 KB

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