clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
138 lines (120 loc) • 5.84 kB
text/typescript
import { Event } from "@clarity-types/data";
import { PointerState, Setting } from "@clarity-types/interaction";
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 {
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;
}
// 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 {
let frame = iframe(root);
let d = frame && frame.contentDocument ? frame.contentDocument.documentElement : document.documentElement;
let touches = evt.changedTouches;
let t = time(evt);
if (touches) {
for (let i = 0; i < touches.length; i++) {
let 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:
let length = state.length;
let 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 {
let dx = last.data.x - current.data.x;
let dy = last.data.y - current.data.y;
let distance = Math.sqrt(dx * dx + dy * dy);
let gap = current.time - last.time;
let match = current.data.target === last.data.target;
let 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); }
}