diff --git a/src/record/index.ts b/src/record/index.ts index b5a625a1..c0a67267 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -92,6 +92,16 @@ function record(options: recordOptions) { }, }), ), + inputCb: v => + emit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + ...v, + }, + }), + ), }); }); } catch (error) { diff --git a/src/record/observer.ts b/src/record/observer.ts index 8c0dc3d9..0f07fd2b 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -1,5 +1,5 @@ import { INode, serializeNodeWithId } from 'rrweb-snapshot'; -import { mirror, throttle, on } from '../utils'; +import { mirror, throttle, on, hookSetter } from '../utils'; import { mutationCallBack, textMutation, @@ -14,6 +14,9 @@ import { listenerHandler, scrollCallback, viewportResizeCallback, + inputValue, + inputCallback, + hookResetter, } from '../types'; function initMutationObserver(cb: mutationCallBack): MutationObserver { @@ -214,6 +217,87 @@ function initViewportResizeObserver( return on('resize', updateDimension, window); } +const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; +const HOOK_PROPERTIES: Array<[HTMLElement, string]> = [ + [HTMLInputElement.prototype, 'value'], + [HTMLInputElement.prototype, 'checked'], + [HTMLSelectElement.prototype, 'value'], + [HTMLTextAreaElement.prototype, 'value'], +]; +const lastInputValueMap: WeakMap = new WeakMap(); +function initInputObserver(cb: inputCallback): listenerHandler { + function eventHandler(event: Event) { + const { target } = event; + if ( + !target || + !(target as Element).tagName || + INPUT_TAGS.indexOf((target as Element).tagName) < 0 + ) { + return; + } + const type: string | undefined = (target as HTMLInputElement).type; + const text = (target as HTMLInputElement).value; + let isChecked = false; + if (type === 'radio' || type === 'checkbox') { + isChecked = (target as HTMLInputElement).checked; + } + cbWithDedup(target, { text, isChecked }); + // if a radio was checked + // the other radios with the same name attribute will be unchecked. + const name: string | undefined = (target as HTMLInputElement).name; + if (type === 'radio' && name && isChecked) { + document + .querySelectorAll(`input[type="radio"][name="${name}"]`) + .forEach(el => { + if (el !== target) { + cbWithDedup(el, { + text: (el as HTMLInputElement).value, + isChecked: !isChecked, + }); + } + }); + } + } + function cbWithDedup(target: EventTarget, v: inputValue) { + const lastInputValue = lastInputValueMap.get(target); + if ( + !lastInputValue || + lastInputValue.text !== v.text || + lastInputValue.isChecked !== v.isChecked + ) { + lastInputValueMap.set(target, v); + const id = mirror.getId(target as INode); + cb({ + ...v, + id, + }); + } + } + const handlers: Array = [ + 'input', + 'change', + ].map(eventName => on(eventName, eventHandler)); + const propertyDescriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + ); + if (propertyDescriptor && propertyDescriptor.set) { + handlers.push( + ...HOOK_PROPERTIES.map(p => + hookSetter(p[0], p[1], { + set() { + // mock to a normal event + eventHandler({ target: this } as Event); + }, + }), + ), + ); + } + return () => { + handlers.forEach(h => h()); + }; +} + export default function initObservers(o: observerParam) { const mutationObserver = initMutationObserver(o.mutationCb); const mousemoveHandler = initMousemoveObserver(o.mousemoveCb); @@ -222,11 +306,13 @@ export default function initObservers(o: observerParam) { ); const scrollHandler = initScrollObserver(o.scrollCb); const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb); + const inputHandler = initInputObserver(o.inputCb); return { mutationObserver, mousemoveHandler, mouseInteractionHandler, scrollHandler, viewportResizeHandler, + inputHandler, }; } diff --git a/src/types.ts b/src/types.ts index f2b4f816..b00a37a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,7 @@ export enum IncrementalSource { MouseInteraction, Scroll, ViewportResize, + Input, } export type mutationData = { @@ -60,12 +61,18 @@ export type viewportResizeData = { source: IncrementalSource.ViewportResize; } & viewportResizeDimention; +export type inputData = { + source: IncrementalSource.Input; + id: number; +} & inputValue; + export type incrementalData = | mutationData | mousemoveData | mouseInteractionData | scrollData - | viewportResizeData; + | viewportResizeData + | inputData; export type event = | domContentLoadedEvent @@ -87,6 +94,7 @@ export type observerParam = { mouseInteractionCb: mouseInteractionCallBack; scrollCb: scrollCallback; viewportResizeCb: viewportResizeCallback; + inputCb: inputCallback; }; export type textMutation = { @@ -167,6 +175,13 @@ export type viewportResizeDimention = { export type viewportResizeCallback = (d: viewportResizeDimention) => void; +export type inputValue = { + text: string; + isChecked: boolean; +}; + +export type inputCallback = (v: inputValue & { id: number }) => void; + export type Mirror = { map: idNodeMap; getId: (n: INode) => number; @@ -180,3 +195,4 @@ export type throttleOptions = { }; export type listenerHandler = () => void; +export type hookResetter = () => void; diff --git a/src/utils.ts b/src/utils.ts index 1aa235bc..b33da50a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,9 @@ -import { Mirror, throttleOptions, listenerHandler } from './types'; +import { + Mirror, + throttleOptions, + listenerHandler, + hookResetter, +} from './types'; export function on( type: string, @@ -17,13 +22,14 @@ export const mirror: Mirror = { getNode(id) { return mirror.map[id]; }, + // TODO: use a weakmap to get rid of manually memory management removeNodeFromMap(n) { const id = n.__sn && n.__sn.id; delete mirror.map[id]; }, }; -// copy from underscore +// copy from underscore and modified export function throttle( func: (arg: T) => void, wait: number, @@ -56,3 +62,20 @@ export function throttle( } }; } + +export function hookSetter( + target: T, + key: string | number | symbol, + d: PropertyDescriptor, +): hookResetter { + const original = Object.getOwnPropertyDescriptor(target, key); + Object.defineProperty(target, key, { + set(value) { + d.set!.call(this, value); + if (original && original.set) { + original.set.call(this, value); + } + }, + }); + return () => hookSetter(target, key, original || {}); +}