@leanbase-giangnd/js 0.0.0

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.
Files changed (49) hide show
  1. package/README.md +143 -0
  2. package/dist/index.cjs +6012 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.ts +1484 -0
  5. package/dist/index.mjs +6010 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/leanbase.iife.js +13431 -0
  8. package/dist/leanbase.iife.js.map +1 -0
  9. package/package.json +48 -0
  10. package/src/autocapture-utils.ts +550 -0
  11. package/src/autocapture.ts +415 -0
  12. package/src/config.ts +8 -0
  13. package/src/constants.ts +108 -0
  14. package/src/extensions/rageclick.ts +34 -0
  15. package/src/extensions/replay/external/config.ts +278 -0
  16. package/src/extensions/replay/external/denylist.ts +32 -0
  17. package/src/extensions/replay/external/lazy-loaded-session-recorder.ts +1376 -0
  18. package/src/extensions/replay/external/mutation-throttler.ts +109 -0
  19. package/src/extensions/replay/external/network-plugin.ts +701 -0
  20. package/src/extensions/replay/external/sessionrecording-utils.ts +141 -0
  21. package/src/extensions/replay/external/triggerMatching.ts +422 -0
  22. package/src/extensions/replay/rrweb-plugins/patch.ts +39 -0
  23. package/src/extensions/replay/session-recording.ts +285 -0
  24. package/src/extensions/replay/types/rrweb-types.ts +575 -0
  25. package/src/extensions/replay/types/rrweb.ts +114 -0
  26. package/src/extensions/sampling.ts +26 -0
  27. package/src/iife.ts +87 -0
  28. package/src/index.ts +2 -0
  29. package/src/leanbase-logger.ts +26 -0
  30. package/src/leanbase-persistence.ts +374 -0
  31. package/src/leanbase.ts +457 -0
  32. package/src/page-view.ts +124 -0
  33. package/src/scroll-manager.ts +103 -0
  34. package/src/session-props.ts +114 -0
  35. package/src/sessionid.ts +330 -0
  36. package/src/storage.ts +410 -0
  37. package/src/types/fflate.d.ts +5 -0
  38. package/src/types/rrweb-record.d.ts +8 -0
  39. package/src/types.ts +807 -0
  40. package/src/utils/blocked-uas.ts +162 -0
  41. package/src/utils/element-utils.ts +50 -0
  42. package/src/utils/event-utils.ts +304 -0
  43. package/src/utils/index.ts +222 -0
  44. package/src/utils/logger.ts +26 -0
  45. package/src/utils/request-utils.ts +128 -0
  46. package/src/utils/simple-event-emitter.ts +27 -0
  47. package/src/utils/user-agent-utils.ts +357 -0
  48. package/src/uuidv7.ts +268 -0
  49. package/src/version.ts +1 -0
@@ -0,0 +1,457 @@
1
+ import {
2
+ PostHogCore,
3
+ getFetch,
4
+ isEmptyObject,
5
+ isEmptyString,
6
+ isNumber,
7
+ isObject,
8
+ isString,
9
+ isUndefined,
10
+ } from '@posthog/core'
11
+ import type {
12
+ PostHogEventProperties,
13
+ PostHogFetchOptions,
14
+ PostHogFetchResponse,
15
+ PostHogPersistedProperty,
16
+ } from '@posthog/core'
17
+ import {
18
+ LeanbaseConfig,
19
+ LeanbasegCaptureOptions as LeanbaseCaptureOptions,
20
+ RemoteConfig,
21
+ CaptureResult,
22
+ Properties,
23
+ } from './types'
24
+ import { LeanbasePersistence } from './leanbase-persistence'
25
+ import {
26
+ addEventListener,
27
+ copyAndTruncateStrings,
28
+ document,
29
+ extend,
30
+ isCrossDomainCookie,
31
+ navigator,
32
+ userAgent,
33
+ } from './utils'
34
+ import Config from './config'
35
+ import { Autocapture } from './autocapture'
36
+ import { logger } from './leanbase-logger'
37
+ import { COOKIELESS_MODE_FLAG_PROPERTY, USER_STATE } from './constants'
38
+ import { getEventProperties } from './utils/event-utils'
39
+ import { SessionIdManager } from './sessionid'
40
+ import { SessionPropsManager } from './session-props'
41
+ import { uuidv7 } from './uuidv7'
42
+ import { PageViewManager } from './page-view'
43
+ import { ScrollManager } from './scroll-manager'
44
+ import { isLikelyBot } from './utils/blocked-uas'
45
+ import { SessionRecording } from './extensions/replay/session-recording'
46
+
47
+ const defaultConfig = (): LeanbaseConfig => ({
48
+ host: 'https://i.leanbase.co',
49
+ token: '',
50
+ autocapture: true,
51
+ rageclick: true,
52
+ disable_session_recording: false,
53
+ session_recording: {
54
+ // Force-enable session recording locally unless explicitly disabled via config
55
+ forceClientRecording: true,
56
+ },
57
+ enable_recording_console_log: undefined,
58
+ persistence: 'localStorage+cookie',
59
+ capture_pageview: 'history_change',
60
+ capture_pageleave: 'if_capture_pageview',
61
+ persistence_name: '',
62
+ mask_all_element_attributes: false,
63
+ cookie_expiration: 365,
64
+ cross_subdomain_cookie: isCrossDomainCookie(document?.location),
65
+ custom_campaign_params: [],
66
+ custom_personal_data_properties: [],
67
+ disable_persistence: false,
68
+ mask_personal_data_properties: false,
69
+ secure_cookie: window?.location?.protocol === 'https:',
70
+ mask_all_text: false,
71
+ bootstrap: {},
72
+ session_idle_timeout_seconds: 30 * 60,
73
+ save_campaign_params: true,
74
+ save_referrer: true,
75
+ opt_out_useragent_filter: false,
76
+ properties_string_max_length: 65535,
77
+ loaded: () => {},
78
+ })
79
+
80
+ export class Leanbase extends PostHogCore {
81
+ config: LeanbaseConfig
82
+ scrollManager: ScrollManager
83
+ pageViewManager: PageViewManager
84
+
85
+ replayAutocapture?: Autocapture
86
+ persistence?: LeanbasePersistence
87
+ sessionPersistence?: LeanbasePersistence
88
+ sessionManager?: SessionIdManager
89
+ sessionPropsManager?: SessionPropsManager
90
+ sessionRecording?: SessionRecording
91
+ isRemoteConfigLoaded?: boolean
92
+ personProcessingSetOncePropertiesSent = false
93
+ isLoaded: boolean = false
94
+ initialPageviewCaptured: boolean
95
+ visibilityStateListener: (() => void) | null
96
+
97
+ constructor(token: string, config?: Partial<LeanbaseConfig>) {
98
+ const mergedConfig = extend(defaultConfig(), config || {}, {
99
+ token,
100
+ })
101
+ super(token, mergedConfig)
102
+ this.config = mergedConfig as LeanbaseConfig
103
+ this.visibilityStateListener = null
104
+ this.initialPageviewCaptured = false
105
+ this.scrollManager = new ScrollManager(this)
106
+ this.pageViewManager = new PageViewManager(this)
107
+ this.init(token, mergedConfig)
108
+ }
109
+
110
+ init(token: string, config: Partial<LeanbaseConfig>) {
111
+ this.setConfig(
112
+ extend(defaultConfig(), config, {
113
+ token,
114
+ })
115
+ )
116
+ this.isLoaded = true
117
+ this.persistence = new LeanbasePersistence(this.config)
118
+
119
+ if (this.config.cookieless_mode !== 'always') {
120
+ this.sessionManager = new SessionIdManager(this)
121
+ this.sessionPropsManager = new SessionPropsManager(this, this.sessionManager, this.persistence)
122
+ }
123
+
124
+ this.replayAutocapture = new Autocapture(this)
125
+ this.replayAutocapture.startIfEnabled()
126
+
127
+ if (this.sessionManager && this.config.cookieless_mode !== 'always') {
128
+ this.sessionRecording = new SessionRecording(this)
129
+ this.sessionRecording.startIfEnabledOrStop()
130
+ }
131
+
132
+ if (this.config.preloadFeatureFlags !== false) {
133
+ this.reloadFeatureFlags()
134
+ }
135
+
136
+ this.config.loaded?.(this)
137
+ if (this.config.capture_pageview) {
138
+ setTimeout(() => {
139
+ if (this.config.cookieless_mode === 'always') {
140
+ this.captureInitialPageview()
141
+ }
142
+ }, 1)
143
+ }
144
+
145
+ addEventListener(document, 'DOMContentLoaded', () => {
146
+ this.loadRemoteConfig()
147
+ })
148
+ addEventListener(window, 'onpagehide' in self ? 'pagehide' : 'unload', this.capturePageLeave.bind(this), {
149
+ passive: false,
150
+ })
151
+ }
152
+
153
+ captureInitialPageview(): void {
154
+ if (!document) {
155
+ return
156
+ }
157
+
158
+ if (document.visibilityState !== 'visible') {
159
+ if (!this.visibilityStateListener) {
160
+ this.visibilityStateListener = this.captureInitialPageview.bind(this)
161
+ addEventListener(document, 'visibilitychange', this.visibilityStateListener)
162
+ }
163
+
164
+ return
165
+ }
166
+
167
+ if (!this.initialPageviewCaptured) {
168
+ this.initialPageviewCaptured = true
169
+ this.capture('$pageview', { title: document.title })
170
+
171
+ if (this.visibilityStateListener) {
172
+ document.removeEventListener('visibilitychange', this.visibilityStateListener)
173
+ this.visibilityStateListener = null
174
+ }
175
+ }
176
+ }
177
+
178
+ capturePageLeave() {
179
+ const { capture_pageleave, capture_pageview } = this.config
180
+ if (
181
+ capture_pageleave === true ||
182
+ (capture_pageleave === 'if_capture_pageview' &&
183
+ (capture_pageview === true || capture_pageview === 'history_change'))
184
+ ) {
185
+ this.capture('$pageleave')
186
+ }
187
+ }
188
+
189
+ async loadRemoteConfig() {
190
+ if (!this.isRemoteConfigLoaded) {
191
+ const remoteConfig = await this.reloadRemoteConfigAsync()
192
+ if (remoteConfig) {
193
+ this.onRemoteConfig(remoteConfig as RemoteConfig)
194
+ }
195
+ }
196
+ }
197
+
198
+ onRemoteConfig(config: RemoteConfig): void {
199
+ if (!(document && document.body)) {
200
+ setTimeout(() => {
201
+ this.onRemoteConfig(config)
202
+ }, 500)
203
+ return
204
+ }
205
+
206
+ this.isRemoteConfigLoaded = true
207
+ this.replayAutocapture?.onRemoteConfig(config)
208
+ this.sessionRecording?.onRemoteConfig(config)
209
+ }
210
+
211
+ fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> {
212
+ const fetchFn = getFetch()
213
+ if (!fetchFn) {
214
+ return Promise.reject(new Error('Fetch API is not available in this environment.'))
215
+ }
216
+
217
+ return fetchFn(url, options)
218
+ }
219
+
220
+ setConfig(config: Partial<LeanbaseConfig>): void {
221
+ const oldConfig = { ...this.config }
222
+ if (isObject(config)) {
223
+ extend(this.config, config)
224
+ this.persistence?.update_config(this.config, oldConfig)
225
+ this.replayAutocapture?.startIfEnabled()
226
+ this.sessionRecording?.startIfEnabledOrStop()
227
+ }
228
+
229
+ const isTempStorage = this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory'
230
+ this.sessionPersistence = isTempStorage
231
+ ? this.persistence
232
+ : new LeanbasePersistence({ ...this.config, persistence: 'sessionStorage' })
233
+ }
234
+
235
+ getLibraryId(): string {
236
+ return 'leanbase'
237
+ }
238
+
239
+ getLibraryVersion(): string {
240
+ return Config.LIB_VERSION
241
+ }
242
+
243
+ getCustomUserAgent(): void {
244
+ return
245
+ }
246
+
247
+ getPersistedProperty<T>(key: PostHogPersistedProperty): T | undefined {
248
+ return this.persistence?.get_property(key)
249
+ }
250
+
251
+ get_property<T = any>(key: string): T | undefined {
252
+ return this.persistence?.get_property(key)
253
+ }
254
+
255
+ setPersistedProperty<T>(key: PostHogPersistedProperty, value: T | null): void {
256
+ this.persistence?.set_property(key, value)
257
+ }
258
+
259
+ calculateEventProperties(
260
+ eventName: string,
261
+ eventProperties: PostHogEventProperties,
262
+ timestamp: Date,
263
+ uuid: string,
264
+ readOnly?: boolean
265
+ ): Properties {
266
+ if (!this.persistence || !this.sessionPersistence) {
267
+ return eventProperties
268
+ }
269
+
270
+ timestamp = timestamp || new Date()
271
+ const startTimestamp = readOnly ? undefined : this.persistence?.remove_event_timer(eventName)
272
+ let properties = { ...eventProperties }
273
+ properties['token'] = this.config.token
274
+ if (this.config.cookieless_mode == 'always' || this.config.cookieless_mode == 'on_reject') {
275
+ properties[COOKIELESS_MODE_FLAG_PROPERTY] = true
276
+ }
277
+
278
+ if (eventName === '$snapshot') {
279
+ const persistenceProps = { ...this.persistence.properties() }
280
+ properties['distinct_id'] = persistenceProps.distinct_id
281
+ if (
282
+ !(isString(properties['distinct_id']) || isNumber(properties['distinct_id'])) ||
283
+ isEmptyString(properties['distinct_id'])
284
+ ) {
285
+ logger.error('Invalid distinct_id for replay event. This indicates a bug in your implementation')
286
+ }
287
+ return properties
288
+ }
289
+
290
+ const infoProperties = getEventProperties(
291
+ this.config.mask_personal_data_properties,
292
+ this.config.custom_personal_data_properties
293
+ )
294
+
295
+ if (this.sessionManager) {
296
+ const { sessionId, windowId } = this.sessionManager.checkAndGetSessionAndWindowId(
297
+ readOnly,
298
+ timestamp.getTime()
299
+ )
300
+ properties['$session_id'] = sessionId
301
+ properties['$window_id'] = windowId
302
+ }
303
+
304
+ if (this.sessionPropsManager) {
305
+ extend(properties, this.sessionPropsManager.getSessionProps())
306
+ }
307
+
308
+ try {
309
+ if (this.sessionRecording) {
310
+ extend(properties, this.sessionRecording.sdkDebugProperties)
311
+ }
312
+ } catch (e: any) {
313
+ properties['$sdk_debug_error_capturing_properties'] = String(e)
314
+ }
315
+
316
+ let pageviewProperties: Record<string, any> = this.pageViewManager.doEvent()
317
+ if (eventName === '$pageview' && !readOnly) {
318
+ pageviewProperties = this.pageViewManager.doPageView(timestamp, uuid)
319
+ }
320
+
321
+ if (eventName === '$pageleave' && !readOnly) {
322
+ pageviewProperties = this.pageViewManager.doPageLeave(timestamp)
323
+ }
324
+
325
+ properties = extend(properties, pageviewProperties)
326
+
327
+ if (eventName === '$pageview' && document) {
328
+ properties['title'] = document.title
329
+ }
330
+
331
+ if (!isUndefined(startTimestamp)) {
332
+ const duration_in_ms = timestamp.getTime() - startTimestamp
333
+ properties['$duration'] = parseFloat((duration_in_ms / 1000).toFixed(3))
334
+ }
335
+
336
+ if (userAgent && this.config.opt_out_useragent_filter) {
337
+ properties['$browser_type'] = isLikelyBot(navigator, []) ? 'bot' : 'browser'
338
+ }
339
+
340
+ properties = extend(
341
+ {},
342
+ infoProperties,
343
+ this.persistence.properties(),
344
+ this.sessionPersistence.properties(),
345
+ properties
346
+ )
347
+
348
+ properties['$is_identified'] = this.isIdentified()
349
+ return properties
350
+ }
351
+
352
+ isIdentified(): boolean {
353
+ return (
354
+ this.persistence?.get_property(USER_STATE) === 'identified' ||
355
+ this.sessionPersistence?.get_property(USER_STATE) === 'identified'
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Add additional set_once properties to the event when creating a person profile. This allows us to create the
361
+ * profile with mostly-accurate properties, despite earlier events not setting them. We do this by storing them in
362
+ * persistence.
363
+ * @param dataSetOnce
364
+ */
365
+ calculateSetOnceProperties(dataSetOnce?: Properties): Properties | undefined {
366
+ if (!this.persistence) {
367
+ return dataSetOnce
368
+ }
369
+
370
+ if (this.personProcessingSetOncePropertiesSent) {
371
+ return dataSetOnce
372
+ }
373
+
374
+ const initialProps = this.persistence.get_initial_props()
375
+ const sessionProps = this.sessionPropsManager?.getSetOnceProps()
376
+ const setOnceProperties = extend({}, initialProps, sessionProps || {}, dataSetOnce || {})
377
+ this.personProcessingSetOncePropertiesSent = true
378
+ if (isEmptyObject(setOnceProperties)) {
379
+ return undefined
380
+ }
381
+
382
+ return setOnceProperties
383
+ }
384
+
385
+ capture(event: string, properties?: PostHogEventProperties, options?: LeanbaseCaptureOptions): void {
386
+ if (!this.isLoaded || !this.sessionPersistence || !this.persistence) {
387
+ return
388
+ }
389
+
390
+ if (isUndefined(event) || !isString(event)) {
391
+ logger.error('No event name provided to posthog.capture')
392
+ return
393
+ }
394
+
395
+ if (properties?.$current_url && !isString(properties?.$current_url)) {
396
+ logger.error(
397
+ 'Invalid `$current_url` property provided to `posthog.capture`. Input must be a string. Ignoring provided value.'
398
+ )
399
+ delete properties?.$current_url
400
+ }
401
+
402
+ this.sessionPersistence.update_search_keyword()
403
+
404
+ if (this.config.save_campaign_params) {
405
+ this.sessionPersistence.update_campaign_params()
406
+ }
407
+
408
+ if (this.config.save_referrer) {
409
+ this.sessionPersistence.update_referrer_info()
410
+ }
411
+
412
+ if (this.config.save_campaign_params || this.config.save_referrer) {
413
+ this.persistence.set_initial_person_info()
414
+ }
415
+
416
+ const systemTime = new Date()
417
+ const timestamp = options?.timestamp || systemTime
418
+ const uuid = uuidv7()
419
+ let data: CaptureResult = {
420
+ uuid,
421
+ event,
422
+ properties: this.calculateEventProperties(event, properties || {}, timestamp, uuid),
423
+ }
424
+
425
+ const setProperties = options?.$set
426
+ if (setProperties) {
427
+ data.$set = options?.$set
428
+ }
429
+
430
+ const setOnceProperties = this.calculateSetOnceProperties(options?.$set_once)
431
+ if (setOnceProperties) {
432
+ data.$set_once = setOnceProperties
433
+ }
434
+
435
+ data = copyAndTruncateStrings(data, options?._noTruncate ? null : this.config.properties_string_max_length)
436
+ data.timestamp = timestamp
437
+ if (!isUndefined(options?.timestamp)) {
438
+ data.properties['$event_time_override_provided'] = true
439
+ data.properties['$event_time_override_system_time'] = systemTime
440
+ }
441
+
442
+ const finalSet = { ...data.properties['$set'], ...data['$set'] }
443
+ if (!isEmptyObject(finalSet)) {
444
+ this.setPersonPropertiesForFlags(finalSet)
445
+ }
446
+
447
+ super.capture(data.event, data.properties, options)
448
+ }
449
+
450
+ identify(distinctId?: string, properties?: PostHogEventProperties, options?: LeanbaseCaptureOptions): void {
451
+ super.identify(distinctId, properties, options)
452
+ }
453
+
454
+ destroy(): void {
455
+ this.persistence?.clear()
456
+ }
457
+ }
@@ -0,0 +1,124 @@
1
+ import { window } from './utils'
2
+ import { Leanbase } from './leanbase'
3
+ import { clampToRange, isUndefined, Logger } from '@posthog/core'
4
+ import { extend } from './utils'
5
+ import { logger } from './leanbase-logger'
6
+
7
+ interface PageViewEventProperties {
8
+ $pageview_id?: string
9
+ $prev_pageview_id?: string
10
+ $prev_pageview_pathname?: string
11
+ $prev_pageview_duration?: number // seconds
12
+ $prev_pageview_last_scroll?: number
13
+ $prev_pageview_last_scroll_percentage?: number
14
+ $prev_pageview_max_scroll?: number
15
+ $prev_pageview_max_scroll_percentage?: number
16
+ $prev_pageview_last_content?: number
17
+ $prev_pageview_last_content_percentage?: number
18
+ $prev_pageview_max_content?: number
19
+ $prev_pageview_max_content_percentage?: number
20
+ }
21
+
22
+ // This keeps track of the PageView state (such as the previous PageView's path, timestamp, id, and scroll properties).
23
+ // We store the state in memory, which means that for non-SPA sites, the state will be lost on page reload. This means
24
+ // that non-SPA sites should always send a $pageleave event on any navigation, before the page unloads. For SPA sites,
25
+ // they only need to send a $pageleave event when the user navigates away from the site, as the information is not lost
26
+ // on an internal navigation, and is included as the $prev_pageview_ properties in the next $pageview event.
27
+
28
+ // Practically, this means that to find the scroll properties for a given pageview, you need to find the event where
29
+ // event name is $pageview or $pageleave and where $prev_pageview_id matches the original pageview event's id.
30
+
31
+ export class PageViewManager {
32
+ _currentPageview?: { timestamp: Date; pageViewId: string | undefined; pathname: string | undefined }
33
+ _instance: Leanbase
34
+
35
+ constructor(instance: Leanbase) {
36
+ this._instance = instance
37
+ }
38
+
39
+ doPageView(timestamp: Date, pageViewId?: string): PageViewEventProperties {
40
+ const response = this._previousPageViewProperties(timestamp, pageViewId)
41
+
42
+ // On a pageview we reset the contexts
43
+ this._currentPageview = { pathname: window?.location.pathname ?? '', pageViewId, timestamp }
44
+ this._instance.scrollManager.resetContext()
45
+
46
+ return response
47
+ }
48
+
49
+ doPageLeave(timestamp: Date): PageViewEventProperties {
50
+ return this._previousPageViewProperties(timestamp, this._currentPageview?.pageViewId)
51
+ }
52
+
53
+ doEvent(): PageViewEventProperties {
54
+ return { $pageview_id: this._currentPageview?.pageViewId }
55
+ }
56
+
57
+ private _previousPageViewProperties(timestamp: Date, pageviewId: string | undefined): PageViewEventProperties {
58
+ const previousPageView = this._currentPageview
59
+
60
+ if (!previousPageView) {
61
+ return { $pageview_id: pageviewId }
62
+ }
63
+
64
+ let properties: PageViewEventProperties = {
65
+ $pageview_id: pageviewId,
66
+ $prev_pageview_id: previousPageView.pageViewId,
67
+ }
68
+
69
+ const scrollContext = this._instance.scrollManager.getContext()
70
+
71
+ if (scrollContext && !this._instance.config.disable_scroll_properties) {
72
+ let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } =
73
+ scrollContext
74
+
75
+ if (
76
+ !isUndefined(maxScrollHeight) &&
77
+ !isUndefined(lastScrollY) &&
78
+ !isUndefined(maxScrollY) &&
79
+ !isUndefined(maxContentHeight) &&
80
+ !isUndefined(lastContentY) &&
81
+ !isUndefined(maxContentY)
82
+ ) {
83
+ // Use ceil, so that e.g. scrolling 999.5px of a 1000px page is considered 100% scrolled
84
+ maxScrollHeight = Math.ceil(maxScrollHeight)
85
+ lastScrollY = Math.ceil(lastScrollY)
86
+ maxScrollY = Math.ceil(maxScrollY)
87
+ maxContentHeight = Math.ceil(maxContentHeight)
88
+ lastContentY = Math.ceil(lastContentY)
89
+ maxContentY = Math.ceil(maxContentY)
90
+
91
+ // if the maximum scroll height is near 0, then the percentage is 1
92
+ const lastScrollPercentage =
93
+ maxScrollHeight <= 1 ? 1 : clampToRange(lastScrollY / maxScrollHeight, 0, 1, logger as Logger)
94
+ const maxScrollPercentage =
95
+ maxScrollHeight <= 1 ? 1 : clampToRange(maxScrollY / maxScrollHeight, 0, 1, logger as Logger)
96
+ const lastContentPercentage =
97
+ maxContentHeight <= 1 ? 1 : clampToRange(lastContentY / maxContentHeight, 0, 1, logger as Logger)
98
+ const maxContentPercentage =
99
+ maxContentHeight <= 1 ? 1 : clampToRange(maxContentY / maxContentHeight, 0, 1, logger as Logger)
100
+
101
+ properties = extend(properties, {
102
+ $prev_pageview_last_scroll: lastScrollY,
103
+ $prev_pageview_last_scroll_percentage: lastScrollPercentage,
104
+ $prev_pageview_max_scroll: maxScrollY,
105
+ $prev_pageview_max_scroll_percentage: maxScrollPercentage,
106
+ $prev_pageview_last_content: lastContentY,
107
+ $prev_pageview_last_content_percentage: lastContentPercentage,
108
+ $prev_pageview_max_content: maxContentY,
109
+ $prev_pageview_max_content_percentage: maxContentPercentage,
110
+ })
111
+ }
112
+ }
113
+
114
+ if (previousPageView.pathname) {
115
+ properties.$prev_pageview_pathname = previousPageView.pathname
116
+ }
117
+ if (previousPageView.timestamp) {
118
+ // Use seconds, for consistency with our other duration-related properties like $duration
119
+ properties.$prev_pageview_duration = (timestamp.getTime() - previousPageView.timestamp.getTime()) / 1000
120
+ }
121
+
122
+ return properties
123
+ }
124
+ }
@@ -0,0 +1,103 @@
1
+ import { window } from './utils'
2
+ import { Leanbase } from './leanbase'
3
+ import { addEventListener } from './utils'
4
+ import { isArray } from '@posthog/core'
5
+
6
+ export interface ScrollContext {
7
+ // scroll is how far down the page the user has scrolled,
8
+ // content is how far down the page the user can view content
9
+ // (e.g. if the page is 1000 tall, but the user's screen is only 500 tall,
10
+ // and they don't scroll at all, then scroll is 0 and content is 500)
11
+ maxScrollHeight?: number
12
+ maxScrollY?: number
13
+ lastScrollY?: number
14
+ maxContentHeight?: number
15
+ maxContentY?: number
16
+ lastContentY?: number
17
+ }
18
+
19
+ // This class is responsible for tracking scroll events and maintaining the scroll context
20
+ export class ScrollManager {
21
+ private _context: ScrollContext | undefined
22
+
23
+ constructor(private _instance: Leanbase) {}
24
+
25
+ getContext(): ScrollContext | undefined {
26
+ return this._context
27
+ }
28
+
29
+ resetContext(): ScrollContext | undefined {
30
+ const ctx = this._context
31
+
32
+ // update the scroll properties for the new page, but wait until the next tick
33
+ // of the event loop
34
+ setTimeout(this._updateScrollData, 0)
35
+
36
+ return ctx
37
+ }
38
+
39
+ private _updateScrollData = () => {
40
+ if (!this._context) {
41
+ this._context = {}
42
+ }
43
+
44
+ const el = this.scrollElement()
45
+
46
+ const scrollY = this.scrollY()
47
+ const scrollHeight = el ? Math.max(0, el.scrollHeight - el.clientHeight) : 0
48
+ const contentY = scrollY + (el?.clientHeight || 0)
49
+ const contentHeight = el?.scrollHeight || 0
50
+
51
+ this._context.lastScrollY = Math.ceil(scrollY)
52
+ this._context.maxScrollY = Math.max(scrollY, this._context.maxScrollY ?? 0)
53
+ this._context.maxScrollHeight = Math.max(scrollHeight, this._context.maxScrollHeight ?? 0)
54
+
55
+ this._context.lastContentY = contentY
56
+ this._context.maxContentY = Math.max(contentY, this._context.maxContentY ?? 0)
57
+ this._context.maxContentHeight = Math.max(contentHeight, this._context.maxContentHeight ?? 0)
58
+ }
59
+
60
+ // `capture: true` is required to get scroll events for other scrollable elements
61
+ // on the page, not just the window
62
+ // see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture
63
+ startMeasuringScrollPosition() {
64
+ addEventListener(window, 'scroll', this._updateScrollData, { capture: true })
65
+ addEventListener(window, 'scrollend', this._updateScrollData, { capture: true })
66
+ addEventListener(window, 'resize', this._updateScrollData)
67
+ }
68
+
69
+ public scrollElement(): Element | undefined {
70
+ if (this._instance.config.scroll_root_selector) {
71
+ const selectors = isArray(this._instance.config.scroll_root_selector)
72
+ ? this._instance.config.scroll_root_selector
73
+ : [this._instance.config.scroll_root_selector]
74
+ for (const selector of selectors) {
75
+ const element = window?.document.querySelector(selector)
76
+ if (element) {
77
+ return element
78
+ }
79
+ }
80
+ return undefined
81
+ } else {
82
+ return window?.document.documentElement
83
+ }
84
+ }
85
+
86
+ public scrollY(): number {
87
+ if (this._instance.config.scroll_root_selector) {
88
+ const element = this.scrollElement()
89
+ return (element && element.scrollTop) || 0
90
+ } else {
91
+ return window ? window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 : 0
92
+ }
93
+ }
94
+
95
+ public scrollX(): number {
96
+ if (this._instance.config.scroll_root_selector) {
97
+ const element = this.scrollElement()
98
+ return (element && element.scrollLeft) || 0
99
+ } else {
100
+ return window ? window.scrollX || window.pageXOffset || window.document.documentElement.scrollLeft || 0 : 0
101
+ }
102
+ }
103
+ }