@gemx-dev/clarity-js 0.8.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/build/clarity.extended.js +1 -0
- package/build/clarity.insight.js +1 -0
- package/build/clarity.js +6433 -0
- package/build/clarity.livechat.js +1 -0
- package/build/clarity.min.js +1 -0
- package/build/clarity.module.js +6429 -0
- package/build/clarity.performance.js +1 -0
- package/build/dynamic/clarity.crisp.js +1 -0
- package/build/dynamic/clarity.tidio.js +1 -0
- package/package.json +55 -0
- package/rollup.config.ts +161 -0
- package/src/clarity.ts +71 -0
- package/src/core/api.ts +8 -0
- package/src/core/config.ts +29 -0
- package/src/core/copy.ts +3 -0
- package/src/core/dynamic.ts +57 -0
- package/src/core/event.ts +53 -0
- package/src/core/hash.ts +19 -0
- package/src/core/history.ts +71 -0
- package/src/core/index.ts +81 -0
- package/src/core/measure.ts +19 -0
- package/src/core/report.ts +35 -0
- package/src/core/scrub.ts +202 -0
- package/src/core/task.ts +181 -0
- package/src/core/throttle.ts +46 -0
- package/src/core/time.ts +26 -0
- package/src/core/timeout.ts +10 -0
- package/src/core/version.ts +2 -0
- package/src/data/baseline.ts +162 -0
- package/src/data/compress.ts +31 -0
- package/src/data/consent.ts +75 -0
- package/src/data/custom.ts +23 -0
- package/src/data/dimension.ts +53 -0
- package/src/data/encode.ts +155 -0
- package/src/data/envelope.ts +53 -0
- package/src/data/extract.ts +211 -0
- package/src/data/index.ts +50 -0
- package/src/data/limit.ts +44 -0
- package/src/data/metadata.ts +408 -0
- package/src/data/metric.ts +51 -0
- package/src/data/ping.ts +36 -0
- package/src/data/signal.ts +30 -0
- package/src/data/summary.ts +34 -0
- package/src/data/token.ts +39 -0
- package/src/data/upgrade.ts +44 -0
- package/src/data/upload.ts +333 -0
- package/src/data/variable.ts +84 -0
- package/src/diagnostic/encode.ts +40 -0
- package/src/diagnostic/fraud.ts +37 -0
- package/src/diagnostic/index.ts +13 -0
- package/src/diagnostic/internal.ts +28 -0
- package/src/diagnostic/script.ts +35 -0
- package/src/dynamic/agent/blank.ts +2 -0
- package/src/dynamic/agent/crisp.ts +40 -0
- package/src/dynamic/agent/encode.ts +25 -0
- package/src/dynamic/agent/index.ts +8 -0
- package/src/dynamic/agent/livechat.ts +58 -0
- package/src/dynamic/agent/tidio.ts +44 -0
- package/src/global.ts +6 -0
- package/src/index.ts +9 -0
- package/src/insight/blank.ts +14 -0
- package/src/insight/encode.ts +61 -0
- package/src/insight/snapshot.ts +115 -0
- package/src/interaction/change.ts +38 -0
- package/src/interaction/click.ts +163 -0
- package/src/interaction/clipboard.ts +32 -0
- package/src/interaction/encode.ts +207 -0
- package/src/interaction/focus.ts +25 -0
- package/src/interaction/index.ts +60 -0
- package/src/interaction/input.ts +58 -0
- package/src/interaction/pointer.ts +137 -0
- package/src/interaction/resize.ts +50 -0
- package/src/interaction/scroll.ts +129 -0
- package/src/interaction/selection.ts +66 -0
- package/src/interaction/submit.ts +30 -0
- package/src/interaction/timeline.ts +69 -0
- package/src/interaction/unload.ts +26 -0
- package/src/interaction/visibility.ts +28 -0
- package/src/layout/animation.ts +133 -0
- package/src/layout/custom.ts +43 -0
- package/src/layout/discover.ts +31 -0
- package/src/layout/document.ts +46 -0
- package/src/layout/dom.ts +439 -0
- package/src/layout/encode.ts +154 -0
- package/src/layout/index.ts +42 -0
- package/src/layout/mutation.ts +412 -0
- package/src/layout/node.ts +294 -0
- package/src/layout/offset.ts +19 -0
- package/src/layout/region.ts +151 -0
- package/src/layout/schema.ts +63 -0
- package/src/layout/selector.ts +82 -0
- package/src/layout/style.ts +160 -0
- package/src/layout/target.ts +32 -0
- package/src/layout/traverse.ts +28 -0
- package/src/performance/blank.ts +10 -0
- package/src/performance/encode.ts +31 -0
- package/src/performance/index.ts +12 -0
- package/src/performance/interaction.ts +125 -0
- package/src/performance/navigation.ts +31 -0
- package/src/performance/observer.ts +112 -0
- package/src/queue.ts +33 -0
- package/test/core.test.ts +139 -0
- package/test/helper.ts +167 -0
- package/test/html/core.html +34 -0
- package/test/stub.test.ts +7 -0
- package/test/tsconfig.test.json +6 -0
- package/tsconfig.json +21 -0
- package/tslint.json +33 -0
- package/types/agent.d.ts +39 -0
- package/types/core.d.ts +150 -0
- package/types/data.d.ts +568 -0
- package/types/diagnostic.d.ts +24 -0
- package/types/global.d.ts +30 -0
- package/types/index.d.ts +40 -0
- package/types/interaction.d.ts +174 -0
- package/types/layout.d.ts +277 -0
- package/types/performance.d.ts +31 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { Constant, Source } from "@clarity-types/layout";
|
|
2
|
+
import { Code, Dimension, Severity } from "@clarity-types/data";
|
|
3
|
+
import * as dom from "./dom";
|
|
4
|
+
import * as event from "@src/core/event";
|
|
5
|
+
import * as dimension from "@src/data/dimension";
|
|
6
|
+
import * as internal from "@src/diagnostic/internal";
|
|
7
|
+
import * as interaction from "@src/interaction";
|
|
8
|
+
import * as mutation from "@src/layout/mutation";
|
|
9
|
+
import * as schema from "@src/layout/schema";
|
|
10
|
+
import * as custom from "@src/layout/custom";
|
|
11
|
+
import { checkDocumentStyles } from "@src/layout/style";
|
|
12
|
+
import { electron } from "@src/data/metadata";
|
|
13
|
+
|
|
14
|
+
const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus", "onerror", "data-drupal-form-submit-last", "aria-label"];
|
|
15
|
+
const newlineRegex = /[\r\n]+/g;
|
|
16
|
+
|
|
17
|
+
export default function (node: Node, source: Source, timestamp: number): Node {
|
|
18
|
+
let child: Node = null;
|
|
19
|
+
|
|
20
|
+
// Do not track this change if we are attempting to remove a node before discovering it
|
|
21
|
+
if (source === Source.ChildListRemove && dom.has(node) === false) { return child; }
|
|
22
|
+
|
|
23
|
+
// Special handling for text nodes that belong to style nodes
|
|
24
|
+
if (source !== Source.Discover &&
|
|
25
|
+
node.nodeType === Node.TEXT_NODE &&
|
|
26
|
+
node.parentElement &&
|
|
27
|
+
node.parentElement.tagName === "STYLE") {
|
|
28
|
+
node = node.parentNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let add = dom.has(node) === false;
|
|
32
|
+
let call = add ? "add" : "update";
|
|
33
|
+
let parent = node.parentElement ? node.parentElement : null;
|
|
34
|
+
let insideFrame = node.ownerDocument !== document;
|
|
35
|
+
switch (node.nodeType) {
|
|
36
|
+
case Node.DOCUMENT_TYPE_NODE:
|
|
37
|
+
parent = insideFrame && node.parentNode ? dom.iframe(node.parentNode) : parent;
|
|
38
|
+
let docTypePrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
|
|
39
|
+
let doctype = node as DocumentType;
|
|
40
|
+
let docName = doctype.name ? doctype.name : Constant.HTML;
|
|
41
|
+
let docAttributes = { name: docName, publicId: doctype.publicId, systemId: doctype.systemId };
|
|
42
|
+
let docData = { tag: docTypePrefix + Constant.DocumentTag, attributes: docAttributes };
|
|
43
|
+
dom[call](node, parent, docData, source);
|
|
44
|
+
break;
|
|
45
|
+
case Node.DOCUMENT_NODE:
|
|
46
|
+
// We check for regions in the beginning when discovering document and
|
|
47
|
+
// later whenever there are new additions or modifications to DOM (mutations)
|
|
48
|
+
if (node === document) {
|
|
49
|
+
dom.parse(document);
|
|
50
|
+
}
|
|
51
|
+
checkDocumentStyles(node as Document, timestamp);
|
|
52
|
+
observe(node as Document);
|
|
53
|
+
break;
|
|
54
|
+
case Node.DOCUMENT_FRAGMENT_NODE:
|
|
55
|
+
let shadowRoot = (node as ShadowRoot);
|
|
56
|
+
if (shadowRoot.host) {
|
|
57
|
+
dom.parse(shadowRoot);
|
|
58
|
+
let type = typeof (shadowRoot.constructor);
|
|
59
|
+
if (type === Constant.Function && shadowRoot.constructor.toString().indexOf(Constant.NativeCode) >= 0) {
|
|
60
|
+
observe(shadowRoot);
|
|
61
|
+
|
|
62
|
+
// See: https://wicg.github.io/construct-stylesheets/ for more details on adoptedStyleSheets.
|
|
63
|
+
// At the moment, we are only able to capture "open" shadow DOM nodes. If they are closed, they are not accessible.
|
|
64
|
+
// In future we may decide to proxy "attachShadow" call to gain access, but at the moment, we don't want to
|
|
65
|
+
// cause any unintended side effect to the page. We will re-evaluate after we gather more real world data on this.
|
|
66
|
+
let style = Constant.Empty as string;
|
|
67
|
+
let fragmentData = { tag: Constant.ShadowDomTag, attributes: { style } };
|
|
68
|
+
dom[call](node, shadowRoot.host, fragmentData, source);
|
|
69
|
+
} else {
|
|
70
|
+
// If the browser doesn't support shadow DOM natively, we detect that, and send appropriate tag back.
|
|
71
|
+
// The differentiation is important because we don't have to observe pollyfill shadow DOM nodes,
|
|
72
|
+
// the same way we observe real shadow DOM nodes (encapsulation provided by the browser).
|
|
73
|
+
dom[call](node, shadowRoot.host, { tag: Constant.PolyfillShadowDomTag, attributes: {} }, source);
|
|
74
|
+
}
|
|
75
|
+
checkDocumentStyles(node as Document, timestamp);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case Node.TEXT_NODE:
|
|
79
|
+
// In IE11 TEXT_NODE doesn't expose a valid parentElement property. Instead we need to lookup parentNode property.
|
|
80
|
+
parent = parent ? parent : node.parentNode as HTMLElement;
|
|
81
|
+
// Account for this text node only if we are tracking the parent node
|
|
82
|
+
// We do not wish to track text nodes for ignored parent nodes, like script tags
|
|
83
|
+
// Also, we do not track text nodes for STYLE tags
|
|
84
|
+
// The only exception is when we receive a mutation to remove the text node, in that case
|
|
85
|
+
// parent will be null, but we can still process the node by checking it's an update call.
|
|
86
|
+
if (call === "update" || (parent && dom.has(parent) && parent.tagName !== "STYLE" && parent.tagName !== "NOSCRIPT")) {
|
|
87
|
+
let textData = { tag: Constant.TextTag, value: node.nodeValue };
|
|
88
|
+
dom[call](node, parent, textData, source);
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
case Node.ELEMENT_NODE:
|
|
92
|
+
let element = (node as HTMLElement);
|
|
93
|
+
let tag = element.tagName;
|
|
94
|
+
let attributes = getAttributes(element);
|
|
95
|
+
// In some cases, external libraries like vue-fragment, can modify parentNode property to not be in sync with the DOM
|
|
96
|
+
// For correctness, we first look at parentElement and if it not present then fall back to using parentNode
|
|
97
|
+
parent = node.parentElement ? node.parentElement : (node.parentNode ? node.parentNode as HTMLElement : null);
|
|
98
|
+
// If we encounter a node that is part of SVG namespace, prefix the tag with SVG_PREFIX
|
|
99
|
+
if (element.namespaceURI === Constant.SvgNamespace) { tag = Constant.SvgPrefix + tag; }
|
|
100
|
+
|
|
101
|
+
switch (tag) {
|
|
102
|
+
case "HTML":
|
|
103
|
+
parent = insideFrame && parent ? dom.iframe(parent) : parent;
|
|
104
|
+
let htmlPrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
|
|
105
|
+
let htmlData = { tag: htmlPrefix + tag, attributes };
|
|
106
|
+
dom[call](node, parent, htmlData, source);
|
|
107
|
+
break;
|
|
108
|
+
case "SCRIPT":
|
|
109
|
+
if (Constant.Type in attributes && attributes[Constant.Type] === Constant.JsonLD) {
|
|
110
|
+
try {
|
|
111
|
+
schema.ld(JSON.parse((element as HTMLScriptElement).text.replace(newlineRegex, Constant.Empty)));
|
|
112
|
+
} catch { /* do nothing */ }
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
case "NOSCRIPT":
|
|
116
|
+
// keeping the noscript tag but ignoring its contents. Some HTML markup relies on having these tags
|
|
117
|
+
// to maintain parity with the original css view, but we don't want to execute any noscript in Clarity
|
|
118
|
+
let noscriptData = { tag, attributes: {}, value: '' };
|
|
119
|
+
dom[call](node, parent, noscriptData, source);
|
|
120
|
+
break;
|
|
121
|
+
case "META":
|
|
122
|
+
var key = (Constant.Property in attributes ?
|
|
123
|
+
Constant.Property :
|
|
124
|
+
(Constant.Name in attributes ? Constant.Name : null));
|
|
125
|
+
if (key && Constant.Content in attributes) {
|
|
126
|
+
let content = attributes[Constant.Content]
|
|
127
|
+
switch(attributes[key]) {
|
|
128
|
+
case Constant.ogTitle:
|
|
129
|
+
dimension.log(Dimension.MetaTitle, content)
|
|
130
|
+
break;
|
|
131
|
+
case Constant.ogType:
|
|
132
|
+
dimension.log(Dimension.MetaType, content)
|
|
133
|
+
break;
|
|
134
|
+
case Constant.Generator:
|
|
135
|
+
dimension.log(Dimension.Generator, content)
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case "HEAD":
|
|
141
|
+
let head = { tag, attributes };
|
|
142
|
+
let l = insideFrame && node.ownerDocument?.location ? node.ownerDocument.location : location;
|
|
143
|
+
head.attributes[Constant.Base] = l.protocol + "//" + l.host + l.pathname;
|
|
144
|
+
dom[call](node, parent, head, source);
|
|
145
|
+
break;
|
|
146
|
+
case "BASE":
|
|
147
|
+
// Override the auto detected base path to explicit value specified in this tag
|
|
148
|
+
let baseHead = dom.get(node.parentElement);
|
|
149
|
+
if (baseHead) {
|
|
150
|
+
// We create "a" element so we can generate protocol and hostname for relative paths like "/path/"
|
|
151
|
+
let a = document.createElement("a");
|
|
152
|
+
a.href = attributes["href"];
|
|
153
|
+
baseHead.data.attributes[Constant.Base] = a.protocol + "//" + a.host + a.pathname;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case "STYLE":
|
|
157
|
+
let styleData = { tag, attributes, value: getStyleValue(element as HTMLStyleElement) };
|
|
158
|
+
dom[call](node, parent, styleData, source);
|
|
159
|
+
break;
|
|
160
|
+
case "IFRAME":
|
|
161
|
+
let iframe = node as HTMLIFrameElement;
|
|
162
|
+
let frameData = { tag, attributes };
|
|
163
|
+
if (dom.sameorigin(iframe)) {
|
|
164
|
+
mutation.monitor(iframe);
|
|
165
|
+
frameData.attributes[Constant.SameOrigin] = "true";
|
|
166
|
+
if (iframe.contentDocument && iframe.contentWindow && iframe.contentDocument.readyState !== "loading") {
|
|
167
|
+
child = iframe.contentDocument;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (source === Source.ChildListRemove) {
|
|
171
|
+
removeObserver(iframe);
|
|
172
|
+
}
|
|
173
|
+
dom[call](node, parent, frameData, source);
|
|
174
|
+
break;
|
|
175
|
+
case "LINK":
|
|
176
|
+
// electron stylesheets reference the local file system - translating those
|
|
177
|
+
// to inline styles so playback can work
|
|
178
|
+
if (electron && attributes['rel'] === Constant.StyleSheet) {
|
|
179
|
+
for (var styleSheetIndex in Object.keys(document.styleSheets)) {
|
|
180
|
+
var currentStyleSheet = document.styleSheets[styleSheetIndex];
|
|
181
|
+
if (currentStyleSheet.ownerNode == element) {
|
|
182
|
+
let syntheticStyleData = { tag: "STYLE", attributes, value: getCssRules(currentStyleSheet) };
|
|
183
|
+
dom[call](node, parent, syntheticStyleData, source);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// for links that aren't electron style sheets we can process them normally
|
|
190
|
+
let linkData = { tag, attributes };
|
|
191
|
+
dom[call](node, parent, linkData, source);
|
|
192
|
+
break;
|
|
193
|
+
case "VIDEO":
|
|
194
|
+
case "AUDIO":
|
|
195
|
+
case "SOURCE":
|
|
196
|
+
// Ignoring any base64 src attribute for media elements to prevent big unused tokens to be sent and shock the network
|
|
197
|
+
if (Constant.Src in attributes && attributes[Constant.Src].startsWith("data:")) {
|
|
198
|
+
attributes[Constant.Src] = "";
|
|
199
|
+
}
|
|
200
|
+
let mediaTag = { tag, attributes };
|
|
201
|
+
dom[call](node, parent, mediaTag, source);
|
|
202
|
+
break;
|
|
203
|
+
default:
|
|
204
|
+
custom.check(element.localName);
|
|
205
|
+
let data = { tag, attributes };
|
|
206
|
+
if (element.shadowRoot) { child = element.shadowRoot; }
|
|
207
|
+
dom[call](node, parent, data, source);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return child;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function observe(root: Document | ShadowRoot): void {
|
|
218
|
+
if (dom.has(root) || event.has(root)) { return; }
|
|
219
|
+
mutation.observe(root); // Observe mutations for this root node
|
|
220
|
+
interaction.observe(root); // Observe interactions for this root node
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function removeObserver(root: HTMLIFrameElement): void {
|
|
224
|
+
// iframes will have load event listeners and they should be removed when iframe is removed
|
|
225
|
+
// from the document
|
|
226
|
+
event.unbind(root);
|
|
227
|
+
const { doc = null, win = null } = dom.iframeContent(root) || {};
|
|
228
|
+
|
|
229
|
+
if (win) {
|
|
230
|
+
// For iframes, scroll event is observed on content window and this needs to be removed as well
|
|
231
|
+
event.unbind(win);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (doc) {
|
|
235
|
+
// When an iframe is removed, we should also remove all listeners attached to its document
|
|
236
|
+
// to avoid memory leaks.
|
|
237
|
+
event.unbind(doc);
|
|
238
|
+
mutation.disconnect(doc);
|
|
239
|
+
|
|
240
|
+
// Remove iframe and content document from maps tracking them
|
|
241
|
+
dom.removeIFrame(root, doc);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getStyleValue(style: HTMLStyleElement): string {
|
|
246
|
+
// Call trim on the text content to ensure we do not process white spaces ( , \n, \r\n, \t, etc.)
|
|
247
|
+
// Also, check if stylesheet has any data-* attribute, if so process rules instead of looking up text
|
|
248
|
+
// Additionally, check if style node has an id - if so it's at a high risk to have experienced dynamic
|
|
249
|
+
// style updates which would make the textContent out of date with its true style contribution.
|
|
250
|
+
let value = style.textContent ? style.textContent.trim() : Constant.Empty;
|
|
251
|
+
let dataset = style.dataset ? Object.keys(style.dataset).length : 0;
|
|
252
|
+
if (value.length === 0 || dataset > 0 || style.id.length > 0) {
|
|
253
|
+
value = getCssRules(style.sheet as CSSStyleSheet);
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function getCssRules(sheet: CSSStyleSheet): string {
|
|
259
|
+
let value = Constant.Empty as string;
|
|
260
|
+
let cssRules = null;
|
|
261
|
+
// Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain
|
|
262
|
+
try { cssRules = sheet ? sheet.cssRules : []; } catch (e) {
|
|
263
|
+
internal.log(Code.CssRules, Severity.Warning, e ? e.name : null);
|
|
264
|
+
if (e && e.name !== "SecurityError") { throw e; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (cssRules !== null) {
|
|
268
|
+
for (let i = 0; i < cssRules.length; i++) {
|
|
269
|
+
value += cssRules[i].cssText;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getAttributes(element: HTMLElement): { [key: string]: string } {
|
|
277
|
+
let output = {};
|
|
278
|
+
let attributes = element.attributes;
|
|
279
|
+
if (attributes && attributes.length > 0) {
|
|
280
|
+
for (let i = 0; i < attributes.length; i++) {
|
|
281
|
+
let name = attributes[i].name;
|
|
282
|
+
if (IGNORE_ATTRIBUTES.indexOf(name) < 0) {
|
|
283
|
+
output[name] = attributes[i].value;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// For INPUT tags read the dynamic "value" property if an explicit "value" attribute is not set
|
|
289
|
+
if (element.tagName === Constant.InputTag && !(Constant.Value in output) && (element as HTMLInputElement).value) {
|
|
290
|
+
output[Constant.Value] = (element as HTMLInputElement).value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return output;
|
|
294
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { OffsetDistance } from "@clarity-types/core";
|
|
2
|
+
import { iframe } from "@src/layout/dom";
|
|
3
|
+
|
|
4
|
+
export function offset(element: HTMLElement): OffsetDistance {
|
|
5
|
+
let output: OffsetDistance = { x: 0, y: 0 };
|
|
6
|
+
|
|
7
|
+
// Walk up the chain to ensure we compute offset distance correctly
|
|
8
|
+
// In case where we may have nested IFRAMEs, we keep walking up until we get to the top most parent page
|
|
9
|
+
if (element && element.offsetParent) {
|
|
10
|
+
do {
|
|
11
|
+
let parent = element.offsetParent as HTMLElement;
|
|
12
|
+
let frame = parent === null ? iframe(element.ownerDocument) : null;
|
|
13
|
+
output.x += element.offsetLeft;
|
|
14
|
+
output.y += element.offsetTop;
|
|
15
|
+
element = frame ? frame : parent;
|
|
16
|
+
} while (element);
|
|
17
|
+
}
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Event, Setting } from "@clarity-types/data";
|
|
2
|
+
import { InteractionState, RegionData, RegionState, RegionQueue, RegionVisibility } from "@clarity-types/layout";
|
|
3
|
+
import { time } from "@src/core/time";
|
|
4
|
+
import * as dom from "@src/layout/dom";
|
|
5
|
+
import encode from "@src/layout/encode";
|
|
6
|
+
|
|
7
|
+
export let state: RegionState[] = [];
|
|
8
|
+
let regionMap: WeakMap<Node, string> = null; // Maps region nodes => region name
|
|
9
|
+
let regions: { [key: number]: RegionData } = {};
|
|
10
|
+
let queue: RegionQueue[] = [];
|
|
11
|
+
let watch = false;
|
|
12
|
+
let observer: IntersectionObserver = null;
|
|
13
|
+
|
|
14
|
+
export function start(): void {
|
|
15
|
+
reset();
|
|
16
|
+
observer = null;
|
|
17
|
+
regionMap = new WeakMap();
|
|
18
|
+
regions = {};
|
|
19
|
+
queue = [];
|
|
20
|
+
watch = window["IntersectionObserver"] ? true : false;
|
|
21
|
+
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function observe(node: Node, name: string): void {
|
|
25
|
+
if (regionMap.has(node) === false) {
|
|
26
|
+
regionMap.set(node, name);
|
|
27
|
+
observer = observer === null && watch ? new IntersectionObserver(handler, {
|
|
28
|
+
// Get notified as intersection continues to change
|
|
29
|
+
// This allows us to process regions that get partially hidden during the lifetime of the page
|
|
30
|
+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
|
|
31
|
+
// By default, intersection observers only fire an event when even a single pixel is visible and not thereafter.
|
|
32
|
+
threshold: [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
|
|
33
|
+
}) : observer;
|
|
34
|
+
if (observer && node && node.nodeType === Node.ELEMENT_NODE) {
|
|
35
|
+
observer.observe(node as Element);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function exists(node: Node): boolean {
|
|
41
|
+
// Check if regionMap is not null before looking up a node
|
|
42
|
+
// Since, dom module stops after region module, it's possible that we may set regionMap to be null
|
|
43
|
+
// and still attempt to call exists on a late coming DOM mutation (or addition), effectively causing a script error
|
|
44
|
+
return regionMap && regionMap.has(node);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function track(id: number, event: Event): void {
|
|
48
|
+
let node = dom.getNode(id);
|
|
49
|
+
let data = id in regions ? regions[id] : { id, visibility: RegionVisibility.Rendered, interaction: InteractionState.None, name: regionMap.get(node) };
|
|
50
|
+
|
|
51
|
+
// Determine the interaction state based on incoming event
|
|
52
|
+
let interaction = InteractionState.None;
|
|
53
|
+
switch (event) {
|
|
54
|
+
case Event.Click: interaction = InteractionState.Clicked; break;
|
|
55
|
+
case Event.Input: interaction = InteractionState.Input; break;
|
|
56
|
+
}
|
|
57
|
+
// Process updates to this region, if applicable
|
|
58
|
+
process(node, data, interaction, data.visibility);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function compute(): void {
|
|
62
|
+
// Process any regions where we couldn't resolve an "id" for at the time of last intersection observer event
|
|
63
|
+
// This could happen in cases where elements are not yet processed by Clarity's virtual DOM but browser reports a change, regardless.
|
|
64
|
+
// For those cases we add them to the queue and re-process them below
|
|
65
|
+
let q = [];
|
|
66
|
+
for (let r of queue) {
|
|
67
|
+
let id = dom.getId(r.node);
|
|
68
|
+
if (id) {
|
|
69
|
+
r.state.data.id = id;
|
|
70
|
+
regions[id] = r.state.data;
|
|
71
|
+
state.push(r.state);
|
|
72
|
+
} else { q.push(r); }
|
|
73
|
+
}
|
|
74
|
+
queue = q;
|
|
75
|
+
|
|
76
|
+
// Schedule encode only when we have at least one valid data entry
|
|
77
|
+
if (state.length > 0) { encode(Event.Region); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handler(entries: IntersectionObserverEntry[]): void {
|
|
81
|
+
for (let entry of entries) {
|
|
82
|
+
let target = entry.target;
|
|
83
|
+
let rect = entry.boundingClientRect;
|
|
84
|
+
let overlap = entry.intersectionRect;
|
|
85
|
+
let viewport = entry.rootBounds;
|
|
86
|
+
// Only capture regions that have non-zero width or height to avoid tracking and sending regions
|
|
87
|
+
// that cannot ever be seen by the user. In some cases, websites will have a multiple copy of the same region
|
|
88
|
+
// like search box - one for desktop, and another for mobile. In those cases, CSS media queries determine which one should be visible.
|
|
89
|
+
// Also, if these regions ever become non-zero width or height (through AJAX, user action or orientation change) - we will automatically start monitoring them from that point onwards
|
|
90
|
+
if (regionMap.has(target) && rect.width + rect.height > 0 && viewport && viewport.width > 0 && viewport.height > 0) {
|
|
91
|
+
let id = target ? dom.getId(target) : null;
|
|
92
|
+
let data = id in regions ? regions[id] : { id, name: regionMap.get(target), interaction: InteractionState.None, visibility: RegionVisibility.Rendered };
|
|
93
|
+
|
|
94
|
+
// For regions that have relatively smaller area, we look at intersection ratio and see the overlap relative to element's area
|
|
95
|
+
// However, for larger regions, area of regions could be bigger than viewport and therefore comparison is relative to visible area
|
|
96
|
+
let viewportRatio = overlap ? (overlap.width * overlap.height * 1.0) / (viewport.width * viewport.height) : 0;
|
|
97
|
+
let visible = viewportRatio > Setting.ViewportIntersectionRatio || entry.intersectionRatio > Setting.IntersectionRatio;
|
|
98
|
+
// If an element is either visible or was visible and has been scrolled to the end
|
|
99
|
+
// i.e. Scrolled to end is determined by if the starting position of the element + the window height is more than the total element height.
|
|
100
|
+
// starting position is relative to the viewport - so Intersection observer returns a negative value for rect.top to indicate that the element top is above the viewport
|
|
101
|
+
let scrolledToEnd = (visible || data.visibility == RegionVisibility.Visible) && Math.abs(rect.top) + viewport.height > rect.height;
|
|
102
|
+
// Process updates to this region, if applicable
|
|
103
|
+
process(target, data, data.interaction,
|
|
104
|
+
(scrolledToEnd ?
|
|
105
|
+
RegionVisibility.ScrolledToEnd :
|
|
106
|
+
(visible ? RegionVisibility.Visible : RegionVisibility.Rendered)));
|
|
107
|
+
|
|
108
|
+
// Stop observing this element now that we have already received scrolled signal
|
|
109
|
+
if (data.visibility >= RegionVisibility.ScrolledToEnd && observer) { observer.unobserve(target); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (state.length > 0) { encode(Event.Region); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function process(n: Node, d: RegionData, s: InteractionState, v: RegionVisibility): void {
|
|
116
|
+
// Check if received a state that supersedes existing state
|
|
117
|
+
let updated = s > d.interaction || v > d.visibility;
|
|
118
|
+
d.interaction = s > d.interaction ? s : d.interaction;
|
|
119
|
+
d.visibility = v > d.visibility ? v : d.visibility;
|
|
120
|
+
// If the corresponding node is already discovered, update the internal state
|
|
121
|
+
// Otherwise, track it in a queue to reprocess later.
|
|
122
|
+
if (d.id) {
|
|
123
|
+
if ((d.id in regions && updated) || !(d.id in regions)) {
|
|
124
|
+
regions[d.id] = d;
|
|
125
|
+
state.push(clone(d));
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// Get the time before adding to queue to ensure accurate event time
|
|
129
|
+
queue.push({node: n, state: clone(d)});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function clone(r: RegionData): RegionState {
|
|
134
|
+
return { time: time(), data: { id: r.id, interaction: r.interaction, visibility: r.visibility, name: r.name }};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function reset(): void {
|
|
138
|
+
state = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function stop(): void {
|
|
142
|
+
reset();
|
|
143
|
+
regionMap = null;
|
|
144
|
+
regions = {};
|
|
145
|
+
queue = [];
|
|
146
|
+
if (observer) {
|
|
147
|
+
observer.disconnect();
|
|
148
|
+
observer = null;
|
|
149
|
+
}
|
|
150
|
+
watch = false;
|
|
151
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Dimension, Metric, Setting } from "@clarity-types/data";
|
|
2
|
+
import { Constant, JsonLD } from "@clarity-types/layout";
|
|
3
|
+
import * as dimension from "@src/data/dimension";
|
|
4
|
+
import * as metric from "@src/data/metric";
|
|
5
|
+
|
|
6
|
+
const digitsRegex = /[^0-9\.]/g;
|
|
7
|
+
|
|
8
|
+
/* JSON+LD (Linked Data) Recursive Parser */
|
|
9
|
+
export function ld(json: any): void {
|
|
10
|
+
for (let key of Object.keys(json)) {
|
|
11
|
+
let value = json[key];
|
|
12
|
+
if (key === JsonLD.Type && typeof value === "string") {
|
|
13
|
+
value = value.toLowerCase();
|
|
14
|
+
/* Normalizations */
|
|
15
|
+
value = value.indexOf(JsonLD.Article) >= 0 || value.indexOf(JsonLD.Posting) >= 0 ? JsonLD.Article : value;
|
|
16
|
+
switch (value) {
|
|
17
|
+
case JsonLD.Article:
|
|
18
|
+
case JsonLD.Recipe:
|
|
19
|
+
dimension.log(Dimension.SchemaType, json[key]);
|
|
20
|
+
dimension.log(Dimension.AuthorName, json[JsonLD.Creator]);
|
|
21
|
+
dimension.log(Dimension.Headline, json[JsonLD.Headline]);
|
|
22
|
+
break;
|
|
23
|
+
case JsonLD.Product:
|
|
24
|
+
dimension.log(Dimension.SchemaType, json[key]);
|
|
25
|
+
dimension.log(Dimension.ProductName, json[JsonLD.Name]);
|
|
26
|
+
dimension.log(Dimension.ProductSku, json[JsonLD.Sku]);
|
|
27
|
+
if (json[JsonLD.Brand]) { dimension.log(Dimension.ProductBrand, json[JsonLD.Brand][JsonLD.Name]); }
|
|
28
|
+
break;
|
|
29
|
+
case JsonLD.AggregateRating:
|
|
30
|
+
if (json[JsonLD.RatingValue]) {
|
|
31
|
+
metric.max(Metric.RatingValue, num(json[JsonLD.RatingValue], Setting.RatingScale));
|
|
32
|
+
metric.max(Metric.BestRating, num(json[JsonLD.BestRating]));
|
|
33
|
+
metric.max(Metric.WorstRating, num(json[JsonLD.WorstRating]));
|
|
34
|
+
}
|
|
35
|
+
metric.max(Metric.RatingCount, num(json[JsonLD.RatingCount]));
|
|
36
|
+
metric.max(Metric.ReviewCount, num(json[JsonLD.ReviewCount]));
|
|
37
|
+
break;
|
|
38
|
+
case JsonLD.Offer:
|
|
39
|
+
dimension.log(Dimension.ProductAvailability, json[JsonLD.Availability]);
|
|
40
|
+
dimension.log(Dimension.ProductCondition, json[JsonLD.ItemCondition]);
|
|
41
|
+
dimension.log(Dimension.ProductCurrency, json[JsonLD.PriceCurrency]);
|
|
42
|
+
dimension.log(Dimension.ProductSku, json[JsonLD.Sku]);
|
|
43
|
+
metric.max(Metric.ProductPrice, num(json[JsonLD.Price]));
|
|
44
|
+
break;
|
|
45
|
+
case JsonLD.Brand:
|
|
46
|
+
dimension.log(Dimension.ProductBrand, json[JsonLD.Name]);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Continue parsing nested objects
|
|
51
|
+
if (value !== null && typeof(value) === Constant.Object) { ld(value); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function num(input: string | number, scale: number = 1): number {
|
|
56
|
+
if (input !== null) {
|
|
57
|
+
switch (typeof input) {
|
|
58
|
+
case Constant.Number: return Math.round((input as number) * scale);
|
|
59
|
+
case Constant.String: return Math.round(parseFloat((input as string).replace(digitsRegex, Constant.Empty)) * scale);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Character } from "../../types/data";
|
|
2
|
+
import { Constant, Selector, SelectorInput } from "../../types/layout";
|
|
3
|
+
|
|
4
|
+
const excludeClassNames = Constant.ExcludeClassNames.split(Constant.Comma);
|
|
5
|
+
let selectorMap: { [selector: string]: number[] } = {};
|
|
6
|
+
|
|
7
|
+
export function reset(): void {
|
|
8
|
+
selectorMap = {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function get(input: SelectorInput, type: Selector): string {
|
|
12
|
+
let a = input.attributes;
|
|
13
|
+
let prefix = input.prefix ? input.prefix[type] : null;
|
|
14
|
+
let suffix = type === Selector.Alpha ? `${Constant.Tilde}${input.position-1}` : `:nth-of-type(${input.position})`;
|
|
15
|
+
switch (input.tag) {
|
|
16
|
+
case "STYLE":
|
|
17
|
+
case "TITLE":
|
|
18
|
+
case "LINK":
|
|
19
|
+
case "META":
|
|
20
|
+
case Constant.TextTag:
|
|
21
|
+
case Constant.DocumentTag:
|
|
22
|
+
return Constant.Empty;
|
|
23
|
+
case "HTML":
|
|
24
|
+
return Constant.HTML;
|
|
25
|
+
default:
|
|
26
|
+
if (prefix === null) { return Constant.Empty; }
|
|
27
|
+
prefix = `${prefix}${Constant.Separator}`;
|
|
28
|
+
input.tag = input.tag.indexOf(Constant.SvgPrefix) === 0 ? input.tag.substr(Constant.SvgPrefix.length) : input.tag;
|
|
29
|
+
let selector = `${prefix}${input.tag}${suffix}`;
|
|
30
|
+
let id = Constant.Id in a && a[Constant.Id].length > 0 ? a[Constant.Id] : null;
|
|
31
|
+
let classes = input.tag !== Constant.BodyTag && Constant.Class in a && a[Constant.Class].length > 0 ? a[Constant.Class].trim().split(/\s+/).filter(c => filter(c)).join(Constant.Period) : null;
|
|
32
|
+
if (classes && classes.length > 0) {
|
|
33
|
+
if (type === Selector.Alpha) {
|
|
34
|
+
// In Alpha mode, update selector to use class names, with relative positioning within the parent id container.
|
|
35
|
+
// If the node has valid class name(s) then drop relative positioning within the parent path to keep things simple.
|
|
36
|
+
let key = `${getDomPath(prefix)}${input.tag}${Constant.Dot}${classes}`;
|
|
37
|
+
if (!(key in selectorMap)) { selectorMap[key] = []; }
|
|
38
|
+
if (selectorMap[key].indexOf(input.id) < 0) { selectorMap[key].push(input.id); }
|
|
39
|
+
selector = `${key}${Constant.Tilde}${selectorMap[key].indexOf(input.id)}`;
|
|
40
|
+
} else {
|
|
41
|
+
// In Beta mode, we continue to look at query selectors in context of the full page
|
|
42
|
+
selector = `${prefix}${input.tag}.${classes}${suffix}`
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Update selector to use "id" field when available. There are two exceptions:
|
|
46
|
+
// (1) if "id" appears to be an auto generated string token, e.g. guid or a random id containing digits
|
|
47
|
+
// (2) if "id" appears inside a shadow DOM, in which case we continue to prefix up to shadow DOM to prevent conflicts
|
|
48
|
+
selector = id && filter(id) ? `${getDomPrefix(prefix)}${Constant.Hash}${id}` : selector;
|
|
49
|
+
return selector;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getDomPrefix(prefix: string): string {
|
|
54
|
+
const shadowDomStart = prefix.lastIndexOf(Constant.ShadowDomTag);
|
|
55
|
+
const iframeDomStart = prefix.lastIndexOf(`${Constant.IFramePrefix}${Constant.HTML}`);
|
|
56
|
+
const domStart = Math.max(shadowDomStart, iframeDomStart);
|
|
57
|
+
|
|
58
|
+
if (domStart < 0) { return Constant.Empty; }
|
|
59
|
+
|
|
60
|
+
return prefix.substring(0, prefix.indexOf(Constant.Separator, domStart) + 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getDomPath(input: string): string {
|
|
64
|
+
let parts = input.split(Constant.Separator);
|
|
65
|
+
for (let i = 0; i < parts.length; i++) {
|
|
66
|
+
let tIndex = parts[i].indexOf(Constant.Tilde);
|
|
67
|
+
let dIndex = parts[i].indexOf(Constant.Dot);
|
|
68
|
+
parts[i] = parts[i].substring(0, dIndex > 0 ? dIndex : (tIndex > 0 ? tIndex : parts[i].length));
|
|
69
|
+
}
|
|
70
|
+
return parts.join(Constant.Separator);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if the given input string has digits or excluded class names
|
|
74
|
+
function filter(value: string): boolean {
|
|
75
|
+
if (!value) { return false; } // Do not process empty strings
|
|
76
|
+
if (excludeClassNames.some(x => value.toLowerCase().indexOf(x) >= 0)) { return false; }
|
|
77
|
+
for (let i = 0; i < value.length; i++) {
|
|
78
|
+
let c = value.charCodeAt(i);
|
|
79
|
+
if (c >= Character.Zero && c <= Character.Nine) { return false };
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|