clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
169 lines (150 loc) • 6.52 kB
text/typescript
import { BooleanFlag, Constant, Event, Setting } from "@clarity-types/data";
import { BrowsingContext, type ClickState, type TextInfo } from "@clarity-types/interaction";
import type { Box } from "@clarity-types/layout";
import { FunctionNames } from "@clarity-types/performance";
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"];
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);
}
function handler(event: Event, root: Node, evt: MouseEvent): void {
handler.dn = FunctionNames.ClickHandler;
const frame = iframe(root);
const d = frame ? frame.contentDocument.documentElement : document.documentElement;
let x = "pageX" in evt ? Math.round(evt.pageX) : "clientX" in evt ? Math.round((evt as MouseEvent).clientX + d.scrollLeft) : null;
let y = "pageY" in evt ? Math.round(evt.pageY) : "clientY" in evt ? Math.round((evt as MouseEvent).clientY + d.scrollTop) : null;
// In case of iframe, we adjust (x,y) to be relative to top parent's origin
if (frame) {
const distance = offset(frame);
x = x ? x + Math.round(distance.x) : x;
y = y ? y + Math.round(distance.y) : y;
}
const t = target(evt);
// Find nearest anchor tag (<a/>) parent if current target node is part of one
// If present, we use the returned link element to populate text and link properties below
const a = link(t);
// Get layout rectangle for the target element
const 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);
}
const eX = l ? Math.max(Math.floor(((x - l.x) / l.w) * Setting.ClickPrecision), 0) : 0;
const eY = l ? Math.max(Math.floor(((y - l.y) / l.h) * Setting.ClickPrecision), 0) : 0;
// 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,
},
});
schedule(encode.bind(this, event));
}
}
function link(inputNode: Node): HTMLAnchorElement {
let node = inputNode;
while (node && node !== document) {
if (node.nodeType === Node.ELEMENT_NODE) {
const 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.
const 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 {
if (element.nodeType === Node.ELEMENT_NODE) {
const tag = (element as HTMLElement).tagName.toLowerCase();
if (UserInputTags.indexOf(tag) >= 0) {
return BooleanFlag.False;
}
}
return BooleanFlag.True;
}
function layout(element: Element): Box {
let box: Box = null;
const de = document.documentElement;
if (typeof element.getBoundingClientRect === "function") {
// getBoundingClientRect returns rectangle relative positioning to viewport
const 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.
box = {
x: Math.floor(rect.left + ("pageXOffset" in window ? window.pageXOffset : de.scrollLeft)),
y: Math.floor(rect.top + ("pageYOffset" in window ? window.pageYOffset : de.scrollTop)),
w: Math.floor(rect.width),
h: Math.floor(rect.height),
};
}
}
return box;
}
function context(a: HTMLAnchorElement): BrowsingContext {
if (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;
}
export function reset(): void {
state = [];
}
export function stop(): void {
reset();
}