diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index 34c8d8d6d..dc54da222 100644 --- a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts +++ b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts @@ -57,6 +57,7 @@ export enum ObservablesEnum { // ErrorObservable = 'errorObservable', NavigateObservable = 'navigateObservable', MutationObservable = 'mutationObservable', + SelectionObservable = 'selectionObservable', } export interface AllWindowObservables { @@ -65,6 +66,7 @@ export interface AllWindowObservables { [ObservablesEnum.ClickObservable]: Observable>; [ObservablesEnum.MutationObservable]: Observable>; [ObservablesEnum.NavigateObservable]?: Observable>; + [ObservablesEnum.SelectionObservable]?: Observable; } export const autocapturePlugin = ( diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts b/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts index 0af617b02..5fa04f524 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts @@ -104,7 +104,7 @@ export function trackRageClicks({ allObservables: AllWindowObservables; shouldTrackRageClick: shouldTrackEvent; }) { - const { clickObservable }: AllWindowObservables = allObservables; + const { clickObservable, selectionObservable }: AllWindowObservables = allObservables; // Keep track of all clicks within the sliding window let clickWindow: ClickEvent[] = []; @@ -152,7 +152,6 @@ export function trackRageClicks({ if (pendingRageClick) { resolutionValue = getRageClickAnalyticsEvent(clickWindow); } - resetClickWindow(click); } else { clickWindow.push(click); @@ -184,11 +183,25 @@ export function trackRageClicks({ }, ); - return rageClickObservable.subscribe((data: RageClickEvent | null) => { + // reset the click window when a selection change occurs + /* istanbul ignore next */ + const selectionSubscription = selectionObservable?.subscribe(() => { + resetClickWindow(); + }); + + const rageClickSubscription = rageClickObservable.subscribe((data: RageClickEvent | null) => { /* istanbul ignore if */ if (data === null) { return; } amplitude.track(AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT, data.rageClickEvent, { time: data.time }); }); + + return { + unsubscribe: () => { + rageClickSubscription.unsubscribe(); + /* istanbul ignore next */ + selectionSubscription?.unsubscribe(); + }, + }; } diff --git a/packages/plugin-autocapture-browser/src/frustration-plugin.ts b/packages/plugin-autocapture-browser/src/frustration-plugin.ts index c5777ac4e..76947ebba 100644 --- a/packages/plugin-autocapture-browser/src/frustration-plugin.ts +++ b/packages/plugin-autocapture-browser/src/frustration-plugin.ts @@ -23,6 +23,7 @@ export interface AllWindowObservables { [ObservablesEnum.ClickObservable]: Observable>; [ObservablesEnum.MutationObservable]: Observable>; [ObservablesEnum.NavigateObservable]?: Observable>; + [ObservablesEnum.SelectionObservable]?: Observable; } type BrowserEnrichmentPlugin = EnrichmentPlugin; @@ -91,10 +92,51 @@ export const frustrationPlugin = (options: FrustrationInteractionsOptions = {}): ); } + const selectionObservable = multicast( + new Observable((observer) => { + const handler = () => { + const el: HTMLElement | null = document.activeElement as HTMLElement; + + // handle input and textarea + + // if the selectionStart and selectionEnd are the same, it means + // nothing is selected (collapsed) and the cursor position is one point + if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) { + let start: number | null | undefined; + let end: number | null | undefined; + try { + start = (el as HTMLInputElement | HTMLTextAreaElement).selectionStart; + end = (el as HTMLInputElement | HTMLTextAreaElement).selectionEnd; + if (start === end) return; // collapsed + } catch (error) { + // input that doesn't support selectionStart/selectionEnd (like checkbox) + // do nothing here + return; + } + return observer.next(); + } + + // handle non-input elements + + // non-input elements have an attribute called "isCollapsed" which + // if true, indicates there "is currently not any text selected" + // (see https://developer.mozilla.org/en-US/docs/Web/API/Selection/isCollapsed) + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) return; + return observer.next(); + }; + window.document.addEventListener('selectionchange', handler); + return () => { + window.document.removeEventListener('selectionchange', handler); + }; + }), + ); + return { [ObservablesEnum.ClickObservable]: clickObservable as Observable>, [ObservablesEnum.MutationObservable]: enrichedMutationObservable, [ObservablesEnum.NavigateObservable]: enrichedNavigateObservable, + [ObservablesEnum.SelectionObservable]: selectionObservable, }; }; diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/frustration-plugin.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/frustration-plugin.test.ts index 1b08d4e84..c14244857 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/frustration-plugin.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/frustration-plugin.test.ts @@ -1,5 +1,5 @@ -import { frustrationPlugin } from '../../src/frustration-plugin'; -import { BrowserConfig, EnrichmentPlugin, ILogger } from '@amplitude/analytics-core'; +import { AllWindowObservables, frustrationPlugin } from '../../src/frustration-plugin'; +import { BrowserConfig, EnrichmentPlugin, ILogger, Unsubscribable } from '@amplitude/analytics-core'; import { createMockBrowserClient } from '../mock-browser-client'; import { trackDeadClick } from '../../src/autocapture/track-dead-click'; import { trackRageClicks } from '../../src/autocapture/track-rage-click'; @@ -329,5 +329,103 @@ describe('frustrationPlugin', () => { // expect no event listeners left on window.navigation expect((window.navigation as any)._handlers.length).toBe(0); }); + + describe('selection observable', () => { + let plugin: EnrichmentPlugin | undefined; + let rageClickCall: any; + let observables: AllWindowObservables; + let subscription: Unsubscribable | undefined; + let selectionSpy: jest.Mock; + + beforeEach(async () => { + plugin = frustrationPlugin({}); + await plugin?.setup?.(config as BrowserConfig, instance); + rageClickCall = (trackRageClicks as jest.Mock).mock.calls[0][0]; + observables = rageClickCall.allObservables; + selectionSpy = jest.fn(); + subscription = observables.selectionObservable?.subscribe(selectionSpy); + jest.clearAllMocks(); + }); + + afterEach(() => { + subscription?.unsubscribe(); + }); + + it('should trigger on selection highlighted', async () => { + const div = document.createElement('div'); + div.focus(); + + expect(observables).toHaveProperty('selectionObservable'); + + jest.spyOn(window, 'getSelection').mockReturnValue({ + isCollapsed: false, + } as any); + const mockSelectionEvent: any = new Event('selectionchange'); + (window.document as any).dispatchEvent(mockSelectionEvent); + + expect(selectionSpy).toHaveBeenCalled(); + }); + + it('should not trigger on non-input element selection change if selection is collapsed', async () => { + // Trigger a mock selection event + const div = document.createElement('div'); + div.focus(); + (window.document as any).dispatchEvent(new Event('selectionchange')); + expect(selectionSpy).not.toHaveBeenCalled(); + }); + + it('should trigger on input element selection change', async () => { + // Trigger a mock selection event + ['textarea', 'input'].forEach((tag) => { + const input = document.createElement(tag) as HTMLTextAreaElement | HTMLInputElement; + input.value = 'some text here'; // Add text so there's something to select + input.selectionStart = 0; + input.selectionEnd = 10; + document.body.appendChild(input); + input.focus(); // This sets document.activeElement to the input + (window.document as any).dispatchEvent(new Event('selectionchange')); + document.body.removeChild(input); + }); + + expect(selectionSpy).toHaveBeenCalledTimes(2); + }); + + it('should not trigger on input element selection change if selection is collapsed', async () => { + // Trigger a mock selection event on input and textarea elements + ['textarea', 'input'].forEach((tag) => { + const input = document.createElement(tag) as HTMLTextAreaElement | HTMLInputElement; + input.value = 'some text here'; // Add text so there's something to select + input.selectionStart = 0; + input.selectionEnd = 0; + document.body.appendChild(input); + input.focus(); // This sets document.activeElement to the input + (window.document as any).dispatchEvent(new Event('selectionchange')); + document.body.removeChild(input); + }); + + expect(selectionSpy).not.toHaveBeenCalled(); + }); + + it('should not trigger on input element that does not support selectionStart/selectionEnd (like checkbox)', async () => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + // make .selectionStart and .selectionEnd throw an error + // simulating "Chrome" behavior + Object.defineProperty(checkbox, 'selectionStart', { + get: () => { + throw new Error('Not supported'); + }, + }); + Object.defineProperty(checkbox, 'selectionEnd', { + get: () => { + throw new Error('Not supported'); + }, + }); + document.body.appendChild(checkbox); + checkbox.focus(); // This sets document.activeElement to the checkbox + (window.document as any).dispatchEvent(new Event('selectionchange')); + document.body.removeChild(checkbox); + }); + }); }); }); diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts index d0ce0c9b9..f608bccf8 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_RAGE_CLICK_THRESHOLD, DEFAULT_RAGE_CLICK_WINDOW_MS, Observable, + Unsubscribable, } from '@amplitude/analytics-core'; import { trackRageClicks } from '../../src/autocapture/track-rage-click'; import { AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT } from '../../src/constants'; @@ -15,9 +16,11 @@ import { AllWindowObservables } from '../../src/frustration-plugin'; describe('trackRageClicks', () => { let mockAmplitude: jest.Mocked; let clickObservable: Observable; + let selectionObservable: Observable; let allObservables: AllWindowObservables; let shouldTrackRageClick: jest.Mock; let clickObserver: any; + let selectionObserver: any; let subscription: ReturnType; beforeEach(() => { @@ -28,11 +31,15 @@ describe('trackRageClicks', () => { clickObservable = new Observable((observer) => { clickObserver = observer; }); + selectionObservable = new Observable((observer) => { + selectionObserver = observer; + }); allObservables = { [ObservablesEnum.ClickObservable]: clickObservable, [ObservablesEnum.NavigateObservable]: new Observable(() => {}), [ObservablesEnum.MutationObservable]: new Observable(() => {}), + [ObservablesEnum.SelectionObservable]: selectionObservable, }; shouldTrackRageClick = jest.fn().mockReturnValue(true); @@ -49,6 +56,74 @@ describe('trackRageClicks', () => { jest.useRealTimers(); }); + describe('selection change', () => { + let subscription: Unsubscribable | undefined; + let mockElement: HTMLElement; + let ancestorElement: HTMLElement; + let startTime: number; + + beforeEach(async () => { + subscription = trackRageClicks({ + amplitude: mockAmplitude, + allObservables, + shouldTrackRageClick, + }); + mockElement = document.createElement('div'); + ancestorElement = document.createElement('div'); + startTime = Date.now(); + }); + + afterEach(() => { + subscription?.unsubscribe(); + }); + + it('should not track rage clicks when the text selection has changed', async () => { + for (let i = 0; i < DEFAULT_RAGE_CLICK_THRESHOLD; i++) { + clickObserver.next({ + event: { + target: mockElement, + pageX: 100, + pageY: 100, + }, + timestamp: startTime + i * 20, + closestTrackedAncestor: ancestorElement, + targetElementProperties: { id: 'test-element' }, + }); + if (i === DEFAULT_RAGE_CLICK_THRESHOLD - 2) { + jest.advanceTimersByTime(10); + selectionObserver.next(); + } + } + jest.advanceTimersByTime(DEFAULT_RAGE_CLICK_WINDOW_MS + 100); + await jest.runAllTimersAsync(); + expect(mockAmplitude.track).not.toHaveBeenCalled(); + }); + + it('should track rage click if selection changes but 4 clicks happen after selection change', async () => { + for (let i = 0; i < DEFAULT_RAGE_CLICK_THRESHOLD + 3; i++) { + clickObserver.next({ + event: { + target: mockElement, + pageX: 100, + pageY: 100, + }, + timestamp: startTime + i * 20, + closestTrackedAncestor: ancestorElement, + targetElementProperties: { id: 'test-element' }, + }); + + // make the 3rd click trigger a selection change + if (i === 2) { + jest.advanceTimersByTime(10); + selectionObserver.next(); + } + } + jest.advanceTimersByTime(DEFAULT_RAGE_CLICK_WINDOW_MS + 100); + await jest.runAllTimersAsync(); + expect(mockAmplitude.track).toHaveBeenCalled(); + }); + }); + it('should track rage clicks when threshold is met', async () => { // Create a mock element const mockElement = document.createElement('div');