highlightNode.tsx 5.1 KB

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