import { BooleanFlag, Constant, Event, Setting } from "@clarity-types/data";
import { BrowsingContext, ClickSource, ClickState, TextInfo } from "@clarity-types/interaction";
import { Box } from "@clarity-types/layout";
import { FunctionNames } from "@clarity-types/performance";
import config from "@src/core/config";
import { bind } from "@src/core/event";
import { schedule } from "@src/core/task";
import { time } from "@src/core/time";
import { iframe } from "@src/layout/dom";
import { offset } from "@src/layout/offset";
import { target } from "@src/layout/target";
import encode from "./encode";
const UserInputTags = ["input", "textarea", "radio", "button", "canvas", "select"];
const VM_PATTERN = /VM\d/;
export let state: ClickState[] = [];
export function start(): void {
reset();
}
export function observe(root: Node): void {
bind(root, "click", handler.bind(this, Event.Click, root), true);
bind(root, "contextmenu", handler.bind(this, Event.ContextMenu, root), true);
}
function handler(event: Event, root: Node, evt: MouseEvent): void {
let frame = iframe(root);
let d = frame && frame.contentDocument ? frame.contentDocument.documentElement : document.documentElement;
let x = "pageX" in evt ? Math.round(evt.pageX) : ("clientX" in evt ? Math.round(evt["clientX"] + d.scrollLeft) : null);
let y = "pageY" in evt ? Math.round(evt.pageY) : ("clientY" in evt ? Math.round(evt["clientY"] + d.scrollTop) : null);
// In case of iframe, we adjust (x,y) to be relative to top parent's origin
if (frame) {
let distance = offset(frame);
x = x ? x + Math.round(distance.x) : x;
y = y ? y + Math.round(distance.y) : y;
}
let t = target(evt);
// Find nearest anchor tag () parent if current target node is part of one
// If present, we use the returned link element to populate text and link properties below
let a = link(t);
// Get layout rectangle for the target element
let l = layout(t as Element);
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail
// This property helps differentiate between a keyboard navigation vs. pointer click
// In case of a keyboard navigation, we use center of target element as (x,y)
if (evt.detail === 0 && l) {
x = Math.round(l.x + (l.w / 2));
y = Math.round(l.y + (l.h / 2));
}
let relativeCoords = computeRelativeCoordinates(t as Element, x, y, l);
let eX = relativeCoords.eX;
let eY = relativeCoords.eY;
// Check for null values before processing this event
if (x !== null && y !== null) {
const textInfo = text(t);
state.push({
time: time(evt), event, data: {
target: t,
x,
y,
eX,
eY,
button: evt.button,
reaction: reaction(t),
context: context(a),
text: textInfo.text,
link: a ? a.href : null,
hash: null,
trust: evt.isTrusted ? BooleanFlag.True : BooleanFlag.False,
isFullText: textInfo.isFullText,
w: l ? l.w : 0,
h: l ? l.h : 0,
tag: getElementAttribute(t, "tagName").substring(0, Setting.ClickTag),
class: getElementAttribute(t, "className").substring(0, Setting.ClickClass),
id: getElementAttribute(t, "id").substring(0, Setting.ClickId),
source: config.diagnostics && !evt.isTrusted ? source() : ClickSource.Undefined
}
});
schedule(encode.bind(this, event));
}
}
function link(node: Node): HTMLAnchorElement {
while (node && node !== document) {
if (node.nodeType === Node.ELEMENT_NODE) {
let element = node as HTMLElement;
if (element.tagName === "A") {
return element as HTMLAnchorElement;
}
}
node = node.parentNode;
}
return null;
}
function text(element: Node): TextInfo {
let output = null;
let isFullText = false;
if (element) {
// Grab text using "textContent" for most HTMLElements, however, use "value" for HTMLInputElements and "alt" for HTMLImageElement.
let t = element.textContent || String((element as HTMLInputElement).value || '') || (element as HTMLImageElement).alt;
if (t) {
// Replace multiple occurrence of space characters with a single white space
// Also, trim any spaces at the beginning or at the end of string
const trimmedText = t.replace(/\s+/g, Constant.Space).trim();
// Finally, send only first few characters as specified by the Setting
output = trimmedText.substring(0, Setting.ClickText);
isFullText = output.length === trimmedText.length;
}
}
return { text: output, isFullText: isFullText ? BooleanFlag.True : BooleanFlag.False };
}
function reaction(element: Node): BooleanFlag {
const tag = getElementAttribute(element, "tagName");
if (UserInputTags.indexOf(tag) >= 0) {
return BooleanFlag.False;
}
return BooleanFlag.True;
}
function getElementAttribute(element: Node, attribute: "tagName" | "className" | "id"): string {
if (element.nodeType === Node.ELEMENT_NODE) {
const attr = (element as HTMLElement)?.[attribute];
const value = typeof attr === "string" ? attr?.toLowerCase() : "";
return value;
}
return "";
}
function layout(element: Element): Box {
let box: Box = null;
let doc = element.ownerDocument || document;
let de = doc.documentElement;
let win = doc.defaultView || window;
if (typeof element.getBoundingClientRect === "function") {
// getBoundingClientRect returns rectangle relative positioning to viewport
let rect = element.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
// Add viewport's scroll position to rectangle to get position relative to document origin
// Also: using Math.floor() instead of Math.round() because in Edge,
// getBoundingClientRect returns partial pixel values (e.g. 162.5px) and Chrome already
// floors the value (e.g. 162px). This keeps consistent behavior across browsers.
let scrollLeft = "pageXOffset" in win ? win.pageXOffset : de.scrollLeft;
let scrollTop = "pageYOffset" in win ? win.pageYOffset : de.scrollTop;
box = {
x: Math.floor(rect.left + scrollLeft),
y: Math.floor(rect.top + scrollTop),
w: Math.floor(rect.width),
h: Math.floor(rect.height)
};
// If this element is inside an iframe, add the iframe's offset to get parent-page coordinates
let frame = iframe(doc);
if (frame) {
let distance = offset(frame);
box.x += Math.round(distance.x);
box.y += Math.round(distance.y);
}
}
}
return box;
}
function computeRelativeCoordinates(element: Element, x: number, y: number, l: Box): { eX: number, eY: number } {
if (!l) return { eX: 0, eY: 0 };
let box = l;
let el = element;
let eX = Math.max(Math.floor(((x - box.x) / box.w) * Setting.ClickPrecision), 0);
let eY = Math.max(Math.floor(((y - box.y) / box.h) * Setting.ClickPrecision), 0);
// Walk up parent chain if coords exceed bounds (can happen with CSS-rendered text)
// Cap iterations to prevent performance issues with deeply nested DOM
let iterations = 0;
while ((eX > Setting.ClickPrecision || eY > Setting.ClickPrecision) && el.parentElement && iterations < Setting.ClickParentTraversal) {
el = el.parentElement;
iterations++;
box = layout(el);
if (!box) continue;
eX = Math.max(Math.floor(((x - box.x) / box.w) * Setting.ClickPrecision), 0);
eY = Math.max(Math.floor(((y - box.y) / box.h) * Setting.ClickPrecision), 0);
}
return { eX, eY };
}
function context(a: HTMLAnchorElement): BrowsingContext {
if (a && a.hasAttribute(Constant.Target)) {
switch (a.getAttribute(Constant.Target)) {
case Constant.Blank: return BrowsingContext.Blank;
case Constant.Parent: return BrowsingContext.Parent;
case Constant.Top: return BrowsingContext.Top;
}
}
return BrowsingContext.Self;
}
function source(): ClickSource {
source.dn = FunctionNames.ClickSource;
try {
const stack = new Error().stack || "";
const origin = location.origin;
let result = ClickSource.Unknown;
for (const line of stack.split("\n")) {
if (line.indexOf("://") >= 0) {
result = line.indexOf("extension") < 0 && line.indexOf(origin) >= 0
? ClickSource.FirstParty
: ClickSource.ThirdParty;
} else if (line.indexOf("eval") >= 0 || line.indexOf("Function") >= 0 || line.indexOf("= 0 || VM_PATTERN.test(line)) {
result = ClickSource.Eval;
}
}
return result;
} catch {
return ClickSource.Unknown;
}
}
export function reset(): void {
state = [];
}
export function stop(): void {
reset();
}