import { INode, MaskInputOptions } from 'rrweb-snapshot'; import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module'; import { mirror, throttle, on, hookSetter, getWindowHeight, getWindowWidth, isBlocked, isTouchEvent, patch, } from '../utils'; import { mutationCallBack, observerParam, mousemoveCallBack, mousePosition, mouseInteractionCallBack, MouseInteractions, listenerHandler, scrollCallback, styleSheetRuleCallback, viewportResizeCallback, inputValue, inputCallback, hookResetter, blockClass, IncrementalSource, hooksParam, Arguments, mediaInteractionCallback, MediaInteractions, SamplingStrategy, canvasMutationCallback, fontCallback, fontParam, MaskInputFn, } from '../types'; import MutationBuffer from './mutation'; export const mutationBuffer = new MutationBuffer(); function initMutationObserver( cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, ): MutationObserver { // see mutation.ts for details mutationBuffer.init( cb, blockClass, blockSelector, inlineStylesheet, maskInputOptions, recordCanvas, ); const observer = new MutationObserver( mutationBuffer.processMutations.bind(mutationBuffer), ); observer.observe(document, { attributes: true, attributeOldValue: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true, }); return observer; } function initMoveObserver( cb: mousemoveCallBack, sampling: SamplingStrategy, ): listenerHandler { if (sampling.mousemove === false) { return () => {}; } const threshold = typeof sampling.mousemove === 'number' ? sampling.mousemove : 50; let positions: mousePosition[] = []; let timeBaseline: number | null; const wrappedCb = throttle((isTouch: boolean) => { const totalOffset = Date.now() - timeBaseline!; cb( positions.map((p) => { p.timeOffset -= totalOffset; return p; }), isTouch ? IncrementalSource.TouchMove : IncrementalSource.MouseMove, ); positions = []; timeBaseline = null; }, 500); const updatePosition = throttle( (evt) => { const { target } = evt; const { clientX, clientY } = isTouchEvent(evt) ? evt.changedTouches[0] : evt; if (!timeBaseline) { timeBaseline = Date.now(); } positions.push({ x: clientX, y: clientY, id: mirror.getId(target as INode), timeOffset: Date.now() - timeBaseline, }); wrappedCb(isTouchEvent(evt)); }, threshold, { trailing: false, }, ); const handlers = [ on('mousemove', updatePosition), on('touchmove', updatePosition), ]; return () => { handlers.forEach((h) => h()); }; } function initMouseInteractionObserver( cb: mouseInteractionCallBack, blockClass: blockClass, sampling: SamplingStrategy, ): listenerHandler { if (sampling.mouseInteraction === false) { return () => {}; } const disableMap: Record = sampling.mouseInteraction === true || sampling.mouseInteraction === undefined ? {} : sampling.mouseInteraction; const handlers: listenerHandler[] = []; const getHandler = (eventKey: keyof typeof MouseInteractions) => { return (event: MouseEvent | TouchEvent) => { if (isBlocked(event.target as Node, blockClass)) { return; } const id = mirror.getId(event.target as INode); const { clientX, clientY } = isTouchEvent(event) ? event.changedTouches[0] : event; cb({ type: MouseInteractions[eventKey], id, x: clientX, y: clientY, }); }; }; Object.keys(MouseInteractions) .filter( (key) => Number.isNaN(Number(key)) && !key.endsWith('_Departed') && disableMap[key] !== false, ) .forEach((eventKey: keyof typeof MouseInteractions) => { const eventName = eventKey.toLowerCase(); const handler = getHandler(eventKey); handlers.push(on(eventName, handler)); }); return () => { handlers.forEach((h) => h()); }; } function initScrollObserver( cb: scrollCallback, blockClass: blockClass, sampling: SamplingStrategy, ): listenerHandler { const updatePosition = throttle((evt) => { if (!evt.target || isBlocked(evt.target as Node, blockClass)) { return; } const id = mirror.getId(evt.target as INode); if (evt.target === document) { const scrollEl = (document.scrollingElement || document.documentElement)!; cb({ id, x: scrollEl.scrollLeft, y: scrollEl.scrollTop, }); } else { cb({ id, x: (evt.target as HTMLElement).scrollLeft, y: (evt.target as HTMLElement).scrollTop, }); } }, sampling.scroll || 100); return on('scroll', updatePosition); } function initViewportResizeObserver( cb: viewportResizeCallback, ): listenerHandler { const updateDimension = throttle(() => { const height = getWindowHeight(); const width = getWindowWidth(); cb({ width: Number(width), height: Number(height), }); }, 200); return on('resize', updateDimension, window); } export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; const lastInputValueMap: WeakMap = new WeakMap(); function initInputObserver( cb: inputCallback, blockClass: blockClass, ignoreClass: string, maskInputOptions: MaskInputOptions, maskInputFn: MaskInputFn | undefined, sampling: SamplingStrategy, ): listenerHandler { function eventHandler(event: Event) { const { target } = event; if ( !target || !(target as Element).tagName || INPUT_TAGS.indexOf((target as Element).tagName) < 0 || isBlocked(target as Node, blockClass) ) { return; } const type: string | undefined = (target as HTMLInputElement).type; if ( type === 'password' || (target as HTMLElement).classList.contains(ignoreClass) ) { return; } let text = (target as HTMLInputElement).value; let isChecked = false; if (type === 'radio' || type === 'checkbox') { isChecked = (target as HTMLInputElement).checked; } else if ( maskInputOptions[ (target as Element).tagName.toLowerCase() as keyof MaskInputOptions ] || maskInputOptions[type as keyof MaskInputOptions] ) { if (maskInputFn) { text = maskInputFn(text); } else { text = '*'.repeat(text.length); } } 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 events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const handlers: Array< listenerHandler | hookResetter > = events.map((eventName) => on(eventName, eventHandler)); const propertyDescriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', ); const hookProperties: Array<[HTMLElement, string]> = [ [HTMLInputElement.prototype, 'value'], [HTMLInputElement.prototype, 'checked'], [HTMLSelectElement.prototype, 'value'], [HTMLTextAreaElement.prototype, 'value'], // Some UI library use selectedIndex to set select value [HTMLSelectElement.prototype, 'selectedIndex'], ]; if (propertyDescriptor && propertyDescriptor.set) { handlers.push( ...hookProperties.map((p) => hookSetter(p[0], p[1], { set() { // mock to a normal event eventHandler({ target: this } as Event); }, }), ), ); } return () => { handlers.forEach((h) => h()); }; } function initStyleSheetObserver(cb: styleSheetRuleCallback): listenerHandler { const insertRule = CSSStyleSheet.prototype.insertRule; CSSStyleSheet.prototype.insertRule = function (rule: string, index?: number) { const id = mirror.getId(this.ownerNode as INode); if (id !== -1) { cb({ id, adds: [{ rule, index }], }); } return insertRule.apply(this, arguments); }; const deleteRule = CSSStyleSheet.prototype.deleteRule; CSSStyleSheet.prototype.deleteRule = function (index: number) { const id = mirror.getId(this.ownerNode as INode); if (id !== -1) { cb({ id, removes: [{ index }], }); } return deleteRule.apply(this, arguments); }; return () => { CSSStyleSheet.prototype.insertRule = insertRule; CSSStyleSheet.prototype.deleteRule = deleteRule; }; } function initMediaInteractionObserver( mediaInteractionCb: mediaInteractionCallback, blockClass: blockClass, ): listenerHandler { const handler = (type: 'play' | 'pause') => (event: Event) => { const { target } = event; if (!target || isBlocked(target as Node, blockClass)) { return; } mediaInteractionCb({ type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause, id: mirror.getId(target as INode), }); }; const handlers = [on('play', handler('play')), on('pause', handler('pause'))]; return () => { handlers.forEach((h) => h()); }; } function initCanvasMutationObserver( cb: canvasMutationCallback, blockClass: blockClass, ): listenerHandler { const props = Object.getOwnPropertyNames(CanvasRenderingContext2D.prototype); const handlers: listenerHandler[] = []; for (const prop of props) { try { if ( typeof CanvasRenderingContext2D.prototype[ prop as keyof CanvasRenderingContext2D ] !== 'function' ) { continue; } const restoreHandler = patch( CanvasRenderingContext2D.prototype, prop, function (original) { return function ( this: CanvasRenderingContext2D, ...args: Array ) { if (!isBlocked(this.canvas, blockClass)) { setTimeout(() => { const recordArgs = [...args]; if (prop === 'drawImage') { if ( recordArgs[0] && recordArgs[0] instanceof HTMLCanvasElement ) { recordArgs[0] = recordArgs[0].toDataURL(); } } cb({ id: mirror.getId((this.canvas as unknown) as INode), property: prop, args: recordArgs, }); }, 0); } return original.apply(this, args); }; }, ); handlers.push(restoreHandler); } catch { const hookHandler = hookSetter( CanvasRenderingContext2D.prototype, prop, { set(v) { cb({ id: mirror.getId((this.canvas as unknown) as INode), property: prop, args: [v], setter: true, }); }, }, ); handlers.push(hookHandler); } } return () => { handlers.forEach((h) => h()); }; } function initFontObserver(cb: fontCallback): listenerHandler { const handlers: listenerHandler[] = []; const fontMap = new WeakMap(); const originalFontFace = FontFace; // tslint:disable-next-line: no-any (window as any).FontFace = function FontFace( family: string, source: string | ArrayBufferView, descriptors?: FontFaceDescriptors, ) { const fontFace = new originalFontFace(family, source, descriptors); fontMap.set(fontFace, { family, buffer: typeof source !== 'string', descriptors, fontSource: typeof source === 'string' ? source : // tslint:disable-next-line: no-any JSON.stringify(Array.from(new Uint8Array(source as any))), }); return fontFace; }; const restoreHandler = patch(document.fonts, 'add', function (original) { return function (this: FontFaceSet, fontFace: FontFace) { setTimeout(() => { const p = fontMap.get(fontFace); if (p) { cb(p); fontMap.delete(fontFace); } }, 0); return original.apply(this, [fontFace]); }; }); handlers.push(() => { // tslint:disable-next-line: no-any (window as any).FonFace = originalFontFace; }); handlers.push(restoreHandler); return () => { handlers.forEach((h) => h()); }; } function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, mousemoveCb, mouseInteractionCb, scrollCb, viewportResizeCb, inputCb, mediaInteractionCb, styleSheetRuleCb, canvasMutationCb, fontCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { hooks.mutation(...p); } mutationCb(...p); }; o.mousemoveCb = (...p: Arguments) => { if (hooks.mousemove) { hooks.mousemove(...p); } mousemoveCb(...p); }; o.mouseInteractionCb = (...p: Arguments) => { if (hooks.mouseInteraction) { hooks.mouseInteraction(...p); } mouseInteractionCb(...p); }; o.scrollCb = (...p: Arguments) => { if (hooks.scroll) { hooks.scroll(...p); } scrollCb(...p); }; o.viewportResizeCb = (...p: Arguments) => { if (hooks.viewportResize) { hooks.viewportResize(...p); } viewportResizeCb(...p); }; o.inputCb = (...p: Arguments) => { if (hooks.input) { hooks.input(...p); } inputCb(...p); }; o.mediaInteractionCb = (...p: Arguments) => { if (hooks.mediaInteaction) { hooks.mediaInteaction(...p); } mediaInteractionCb(...p); }; o.styleSheetRuleCb = (...p: Arguments) => { if (hooks.styleSheetRule) { hooks.styleSheetRule(...p); } styleSheetRuleCb(...p); }; o.canvasMutationCb = (...p: Arguments) => { if (hooks.canvasMutation) { hooks.canvasMutation(...p); } canvasMutationCb(...p); }; o.fontCb = (...p: Arguments) => { if (hooks.font) { hooks.font(...p); } fontCb(...p); }; } export function initObservers( o: observerParam, hooks: hooksParam = {}, ): listenerHandler { mergeHooks(o, hooks); const mutationObserver = initMutationObserver( o.mutationCb, o.blockClass, o.blockSelector, o.inlineStylesheet, o.maskInputOptions, o.recordCanvas, ); const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling); const mouseInteractionHandler = initMouseInteractionObserver( o.mouseInteractionCb, o.blockClass, o.sampling, ); const scrollHandler = initScrollObserver( o.scrollCb, o.blockClass, o.sampling, ); const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb); const inputHandler = initInputObserver( o.inputCb, o.blockClass, o.ignoreClass, o.maskInputOptions, o.maskInputFn, o.sampling, ); const mediaInteractionHandler = initMediaInteractionObserver( o.mediaInteractionCb, o.blockClass, ); const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb); const canvasMutationObserver = o.recordCanvas ? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass) : () => {}; const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {}; return () => { mutationObserver.disconnect(); mousemoveHandler(); mouseInteractionHandler(); scrollHandler(); viewportResizeHandler(); inputHandler(); mediaInteractionHandler(); styleSheetObserver(); canvasMutationObserver(); fontObserver(); }; }