highlightNode.tsx 5.4 KB

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