mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import type {Replayer} from '@sentry-internal/rrweb';
|
|
|
|
const DEFAULT_HIGHLIGHT_COLOR = 'rgba(168, 196, 236, 0.75)';
|
|
|
|
const highlightsByNodeId: Map<number, {canvas: HTMLCanvasElement}> = new Map();
|
|
const highlightsBySelector: Map<string, {canvas: HTMLCanvasElement}> = new Map();
|
|
|
|
type DrawProps = {annotation: string; color: string; spotlight: boolean};
|
|
|
|
interface AddHighlightByNodeIdParams extends Partial<DrawProps> {
|
|
nodeId: number;
|
|
}
|
|
interface AddHighlightBySelectorParams extends Partial<DrawProps> {
|
|
selector: string;
|
|
}
|
|
|
|
type AddHighlightParams = AddHighlightByNodeIdParams | AddHighlightBySelectorParams;
|
|
|
|
type RemoveHighlightParams =
|
|
| {
|
|
nodeId: number;
|
|
}
|
|
| {
|
|
selector: string;
|
|
};
|
|
|
|
export function clearAllHighlights(replayer: Replayer) {
|
|
for (const nodeId of highlightsByNodeId.keys()) {
|
|
removeHighlightedNode(replayer, {nodeId});
|
|
}
|
|
for (const selector of highlightsBySelector.keys()) {
|
|
removeHighlightedNode(replayer, {selector});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: Replayer, props: RemoveHighlightParams) {
|
|
if ('nodeId' in props) {
|
|
const highlightObj = highlightsByNodeId.get(props.nodeId);
|
|
if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) {
|
|
replayer.wrapper.removeChild(highlightObj.canvas);
|
|
highlightsByNodeId.delete(props.nodeId);
|
|
}
|
|
} else {
|
|
const highlightObj = highlightsBySelector.get(props.selector);
|
|
if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) {
|
|
replayer.wrapper.removeChild(highlightObj.canvas);
|
|
highlightsBySelector.delete(props.selector);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempt to highlight the node inside of a replay recording
|
|
*/
|
|
export function highlightNode(replayer: Replayer, props: AddHighlightParams) {
|
|
// @ts-expect-error mouseTail is private
|
|
const {mouseTail, wrapper} = replayer;
|
|
const mirror = replayer.getMirror();
|
|
|
|
const node =
|
|
'nodeId' in props
|
|
? mirror.getNode(props.nodeId)
|
|
: replayer.iframe.contentDocument?.body.querySelector(props.selector);
|
|
|
|
// 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 ||
|
|
!('getBoundingClientRect' in node) ||
|
|
!replayer.iframe.contentDocument?.body?.contains(node)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// 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 element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
|
|
|
|
if (!element) {
|
|
return null;
|
|
}
|
|
|
|
const canvas = mouseTail.cloneNode();
|
|
const boundingClientRect = element.getBoundingClientRect();
|
|
const drawProps = {
|
|
annotation: props.annotation ?? '',
|
|
color: props.color ?? DEFAULT_HIGHLIGHT_COLOR,
|
|
spotlight: props.spotlight ?? false,
|
|
};
|
|
|
|
drawCtx(canvas, boundingClientRect, drawProps);
|
|
|
|
if ('nodeId' in props) {
|
|
highlightsByNodeId.set(props.nodeId, {canvas});
|
|
} else {
|
|
highlightsBySelector.set(props.selector, {canvas});
|
|
}
|
|
|
|
wrapper.insertBefore(canvas, mouseTail);
|
|
|
|
return {
|
|
canvas,
|
|
};
|
|
}
|
|
|
|
function drawCtx(
|
|
canvas: HTMLCanvasElement,
|
|
{top, left, width, height}: DOMRect,
|
|
{annotation, color, spotlight}: DrawProps
|
|
) {
|
|
const ctx = canvas.getContext('2d') as undefined | CanvasRenderingContext2D;
|
|
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
// TODO(replays): Does not account for scrolling (should we attempt to keep highlight visible, or does it disappear)
|
|
|
|
ctx.fillStyle = color;
|
|
if (spotlight) {
|
|
// Create a screen over the whole area, so only the highlighted part is normal
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.clearRect(left, top, width, height);
|
|
} else {
|
|
// Draw a rectangle to highlight element
|
|
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 {width: textWidth} = ctx.measureText(annotation);
|
|
const textHeight = 30;
|
|
|
|
if (height <= textHeight + 10) {
|
|
// Draw the text outside the box
|
|
|
|
// Draw rect around text
|
|
ctx.fillStyle = 'rgba(30, 30, 30, 0.75)';
|
|
ctx.fillRect(left, top + height, textWidth, textHeight);
|
|
|
|
// Draw text
|
|
ctx.fillStyle = 'white';
|
|
ctx.fillText(annotation, left + textWidth, top + height + textHeight);
|
|
} else {
|
|
// Draw the text inside the clicked element
|
|
|
|
// 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);
|
|
}
|
|
}
|