clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
152 lines (134 loc) • 6.12 kB
text/typescript
import { Event } from "@clarity-types/data";
import { type PointerState, Setting } from "@clarity-types/interaction";
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 { clearTimeout, setTimeout } from "@src/core/timeout";
import { iframe } from "@src/layout/dom";
import { offset } from "@src/layout/offset";
import { target } from "@src/layout/target";
import encode from "./encode";
export let state: PointerState[] = [];
let timeout: number = null;
let hasPrimaryTouch = false;
let primaryTouchId = 0;
const activeTouchPointIds = new Set<number>();
export function start(): void {
reset();
}
export function observe(root: Node): void {
bind(root, "mousedown", mouse.bind(this, Event.MouseDown, root), true);
bind(root, "mouseup", mouse.bind(this, Event.MouseUp, root), true);
bind(root, "mousemove", mouse.bind(this, Event.MouseMove, root), true);
bind(root, "wheel", mouse.bind(this, Event.MouseWheel, root), true);
bind(root, "dblclick", mouse.bind(this, Event.DoubleClick, root), true);
bind(root, "touchstart", touch.bind(this, Event.TouchStart, root), true);
bind(root, "touchend", touch.bind(this, Event.TouchEnd, root), true);
bind(root, "touchmove", touch.bind(this, Event.TouchMove, root), true);
bind(root, "touchcancel", touch.bind(this, Event.TouchCancel, root), true);
}
function mouse(event: Event, root: Node, evt: MouseEvent): void {
mouse.dn = FunctionNames.PointerMouse;
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;
}
// Check for null values before processing this event
if (x !== null && y !== null) {
handler({ time: time(evt), event, data: { target: target(evt), x, y } });
}
}
function touch(event: Event, root: Node, evt: TouchEvent): void {
touch.dn = FunctionNames.PointerTouch;
const frame = iframe(root);
const d = frame ? frame.contentDocument.documentElement : document.documentElement;
const touches = evt.changedTouches;
const t = time(evt);
if (touches) {
for (let i = 0; i < touches.length; i++) {
const entry = touches[i];
let x = "clientX" in entry ? Math.round(entry.clientX + d.scrollLeft) : null;
let y = "clientY" in entry ? Math.round(entry.clientY + d.scrollTop) : null;
x = x && frame ? x + Math.round(frame.offsetLeft) : x;
y = y && frame ? y + Math.round(frame.offsetTop) : y;
// We cannot rely on identifier to determine primary touch as its value doesn't always start with 0.
// Safari/Webkit uses the address of the UITouch object as the identifier value for each touch point.
const id = "identifier" in entry ? entry.identifier : undefined;
switch (event) {
case Event.TouchStart:
if (activeTouchPointIds.size === 0) {
// Track presence of primary touch separately to handle scenarios when same id is repeated
hasPrimaryTouch = true;
primaryTouchId = id;
}
activeTouchPointIds.add(id);
break;
case Event.TouchEnd:
case Event.TouchCancel:
activeTouchPointIds.delete(id);
break;
}
const isPrimary = hasPrimaryTouch && primaryTouchId === id;
// Check for null values before processing this event
if (x !== null && y !== null) {
handler({ time: t, event, data: { target: target(evt), x, y, id, isPrimary } });
}
// Reset primary touch point id once touch event ends
if (event === Event.TouchCancel || event === Event.TouchEnd) {
if (primaryTouchId === id) {
hasPrimaryTouch = false;
}
}
}
}
}
function handler(current: PointerState): void {
switch (current.event) {
case Event.MouseMove:
case Event.MouseWheel:
case Event.TouchMove: {
const length = state.length;
const last = length > 1 ? state[length - 2] : null;
if (last && similar(last, current)) {
state.pop();
}
state.push(current);
clearTimeout(timeout);
timeout = setTimeout(process, Setting.LookAhead, current.event);
break;
}
default:
state.push(current);
process(current.event);
break;
}
}
function process(event: Event): void {
schedule(encode.bind(this, event));
}
export function reset(): void {
state = [];
}
function similar(last: PointerState, current: PointerState): boolean {
const dx = last.data.x - current.data.x;
const dy = last.data.y - current.data.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const gap = current.time - last.time;
const match = current.data.target === last.data.target;
const sameId = current.data.id !== undefined ? current.data.id === last.data.id : true;
return current.event === last.event && match && distance < Setting.Distance && gap < Setting.PointerInterval && sameId;
}
export function stop(): void {
clearTimeout(timeout);
// Send out any pending pointer events in the pipeline
if (state.length > 0) {
process(state[state.length - 1].event);
}
}