Files
rrweb/packages/rrweb/src/record/observer.ts
Francesco Novy d2582e9a81 feat: Ensure password inputs are masked when switching type (#1170)
* feat: Ensure password inputs are masked when switching type


Apply formatting changes

use data- attribute


ref: Ensure type is always lowercased


add changeset

* extract into util

* Apply formatting changes
2023-03-20 20:13:23 +08:00

1189 lines
33 KiB
TypeScript

import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot';
import type { FontFaceSet } from 'css-font-loading-module';
import {
throttle,
on,
hookSetter,
getInputType,
getWindowScroll,
getWindowHeight,
getWindowWidth,
isBlocked,
isTouchEvent,
patch,
StyleSheetMirror,
} from '../utils';
import type { observerParam, MutationBufferParam } from '../types';
import {
mutationCallBack,
mousemoveCallBack,
mousePosition,
mouseInteractionCallBack,
MouseInteractions,
listenerHandler,
scrollCallback,
styleSheetRuleCallback,
viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
IncrementalSource,
hooksParam,
Arguments,
mediaInteractionCallback,
MediaInteractions,
canvasMutationCallback,
fontCallback,
fontParam,
styleDeclarationCallback,
IWindow,
SelectionRange,
selectionCallback,
} from '@rrweb/types';
import MutationBuffer from './mutation';
import ProcessedNodeManager from './processed-node-manager';
type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
};
type WindowWithAngularZone = IWindow & {
Zone?: {
__symbol__?: (key: string) => string;
};
};
export const mutationBuffers: MutationBuffer[] = [];
export const processedNodeManager = new ProcessedNodeManager();
// Event.path is non-standard and used in some older browsers
type NonStandardEvent = Omit<Event, 'composedPath'> & {
path: EventTarget[];
};
function getEventTarget(event: Event | NonStandardEvent): EventTarget | null {
try {
if ('composedPath' in event) {
const path = event.composedPath();
if (path.length) {
return path[0];
}
} else if ('path' in event && event.path.length) {
return event.path[0];
}
return event.target;
} catch {
return event.target;
}
}
export function initMutationObserver(
options: MutationBufferParam,
rootEl: Node,
): MutationObserver {
const mutationBuffer = new MutationBuffer();
mutationBuffers.push(mutationBuffer);
// see mutation.ts for details
mutationBuffer.init(options);
let mutationObserverCtor =
window.MutationObserver ||
/**
* Some websites may disable MutationObserver by removing it from the window object.
* If someone is using rrweb to build a browser extention or things like it, they
* could not change the website's code but can have an opportunity to inject some
* code before the website executing its JS logic.
* Then they can do this to store the native MutationObserver:
* window.__rrMutationObserver = MutationObserver
*/
(window as WindowWithStoredMutationObserver).__rrMutationObserver;
const angularZoneSymbol = (
window as WindowWithAngularZone
)?.Zone?.__symbol__?.('MutationObserver');
if (
angularZoneSymbol &&
(window as unknown as Record<string, typeof MutationObserver>)[
angularZoneSymbol
]
) {
mutationObserverCtor = (
window as unknown as Record<string, typeof MutationObserver>
)[angularZoneSymbol];
}
const observer = new (mutationObserverCtor as new (
callback: MutationCallback,
) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer));
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
});
return observer;
}
function initMoveObserver({
mousemoveCb,
sampling,
doc,
mirror,
}: observerParam): listenerHandler {
if (sampling.mousemove === false) {
return () => {
//
};
}
const threshold =
typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
const callbackThreshold =
typeof sampling.mousemoveCallback === 'number'
? sampling.mousemoveCallback
: 500;
let positions: mousePosition[] = [];
let timeBaseline: number | null;
const wrappedCb = throttle(
(
source:
| IncrementalSource.MouseMove
| IncrementalSource.TouchMove
| IncrementalSource.Drag,
) => {
const totalOffset = Date.now() - timeBaseline!;
mousemoveCb(
positions.map((p) => {
p.timeOffset -= totalOffset;
return p;
}),
source,
);
positions = [];
timeBaseline = null;
},
callbackThreshold,
);
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
(evt) => {
const target = getEventTarget(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 Node),
timeOffset: Date.now() - timeBaseline,
});
// it is possible DragEvent is undefined even on devices
// that support event 'drag'
wrappedCb(
typeof DragEvent !== 'undefined' && evt instanceof DragEvent
? IncrementalSource.Drag
: evt instanceof MouseEvent
? IncrementalSource.MouseMove
: IncrementalSource.TouchMove,
);
},
threshold,
{
trailing: false,
},
);
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
on('drag', updatePosition, doc),
];
return () => {
handlers.forEach((h) => h());
};
}
function initMouseInteractionObserver({
mouseInteractionCb,
doc,
mirror,
blockClass,
blockSelector,
sampling,
}: observerParam): listenerHandler {
if (sampling.mouseInteraction === false) {
return () => {
//
};
}
const disableMap: Record<string, boolean | undefined> =
sampling.mouseInteraction === true ||
sampling.mouseInteraction === undefined
? {}
: sampling.mouseInteraction;
const handlers: listenerHandler[] = [];
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target, blockClass, blockSelector, true)) {
return;
}
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
if (!e) {
return;
}
const id = mirror.getId(target);
const { clientX, clientY } = e;
mouseInteractionCb({
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, doc));
});
return () => {
handlers.forEach((h) => h());
};
}
export function initScrollObserver({
scrollCb,
doc,
mirror,
blockClass,
blockSelector,
sampling,
}: Pick<
observerParam,
'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling'
>): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) {
return;
}
const id = mirror.getId(target as Node);
if (target === doc && doc.defaultView) {
const scrollLeftTop = getWindowScroll(doc.defaultView);
scrollCb({
id,
x: scrollLeftTop.left,
y: scrollLeftTop.top,
});
} else {
scrollCb({
id,
x: (target as HTMLElement).scrollLeft,
y: (target as HTMLElement).scrollTop,
});
}
}, sampling.scroll || 100);
return on('scroll', updatePosition, doc);
}
function initViewportResizeObserver({
viewportResizeCb,
}: observerParam): listenerHandler {
let lastH = -1;
let lastW = -1;
const updateDimension = throttle(() => {
const height = getWindowHeight();
const width = getWindowWidth();
if (lastH !== height || lastW !== width) {
viewportResizeCb({
width: Number(width),
height: Number(height),
});
lastH = height;
lastW = width;
}
}, 200);
return on('resize', updateDimension, window);
}
function wrapEventWithUserTriggeredFlag(
v: inputValue,
enable: boolean,
): inputValue {
const value = { ...v };
if (!enable) delete value.userTriggered;
return value;
}
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver({
inputCb,
doc,
mirror,
blockClass,
blockSelector,
ignoreClass,
maskInputOptions,
maskInputFn,
sampling,
userTriggeredOnInput,
}: observerParam): listenerHandler {
function eventHandler(event: Event) {
let target = getEventTarget(event) as HTMLElement | null;
const userTriggered = event.isTrusted;
const tagName = target && target.tagName;
/**
* If a site changes the value 'selected' of an option element, the value of its parent element, usually a select element, will be changed as well.
* We can treat this change as a value change of the select element the current target belongs to.
*/
if (target && tagName === 'OPTION') {
target = target.parentElement;
}
if (
!target ||
!tagName ||
INPUT_TAGS.indexOf(tagName) < 0 ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
if (target.classList.contains(ignoreClass)) {
return;
}
let text = (target as HTMLInputElement).value;
let isChecked = false;
const type: Lowercase<string> = getInputType(target) || '';
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
} else if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
text = maskInputValue({
maskInputOptions,
tagName,
type,
value: text,
maskInputFn,
});
}
cbWithDedup(
target,
wrapEventWithUserTriggeredFlag(
{ text, isChecked, userTriggered },
userTriggeredOnInput,
),
);
// 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) {
doc
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => {
if (el !== target) {
cbWithDedup(
el,
wrapEventWithUserTriggeredFlag(
{
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
userTriggered: false,
},
userTriggeredOnInput,
),
);
}
});
}
}
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 Node);
inputCb({
...v,
id,
});
}
}
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<listenerHandler | hookResetter> = events.map(
(eventName) => on(eventName, eventHandler, doc),
);
const currentWindow = doc.defaultView;
if (!currentWindow) {
return () => {
handlers.forEach((h) => h());
};
}
const propertyDescriptor = currentWindow.Object.getOwnPropertyDescriptor(
currentWindow.HTMLInputElement.prototype,
'value',
);
const hookProperties: Array<[HTMLElement, string]> = [
[currentWindow.HTMLInputElement.prototype, 'value'],
[currentWindow.HTMLInputElement.prototype, 'checked'],
[currentWindow.HTMLSelectElement.prototype, 'value'],
[currentWindow.HTMLTextAreaElement.prototype, 'value'],
// Some UI library use selectedIndex to set select value
[currentWindow.HTMLSelectElement.prototype, 'selectedIndex'],
[currentWindow.HTMLOptionElement.prototype, 'selected'],
];
if (propertyDescriptor && propertyDescriptor.set) {
handlers.push(
...hookProperties.map((p) =>
hookSetter<HTMLElement>(
p[0],
p[1],
{
set() {
// mock to a normal event
eventHandler({
target: this as EventTarget,
isTrusted: false, // userTriggered to false as this could well be programmatic
} as Event);
},
},
false,
currentWindow,
),
),
);
}
return () => {
handlers.forEach((h) => h());
};
}
type GroupingCSSRule =
| CSSGroupingRule
| CSSMediaRule
| CSSSupportsRule
| CSSConditionRule;
type GroupingCSSRuleTypes =
| typeof CSSGroupingRule
| typeof CSSMediaRule
| typeof CSSSupportsRule
| typeof CSSConditionRule;
function getNestedCSSRulePositions(rule: CSSRule): number[] {
const positions: number[] = [];
function recurse(childRule: CSSRule, pos: number[]) {
if (
(hasNestedCSSRule('CSSGroupingRule') &&
childRule.parentRule instanceof CSSGroupingRule) ||
(hasNestedCSSRule('CSSMediaRule') &&
childRule.parentRule instanceof CSSMediaRule) ||
(hasNestedCSSRule('CSSSupportsRule') &&
childRule.parentRule instanceof CSSSupportsRule) ||
(hasNestedCSSRule('CSSConditionRule') &&
childRule.parentRule instanceof CSSConditionRule)
) {
const rules = Array.from(
(childRule.parentRule as GroupingCSSRule).cssRules,
);
const index = rules.indexOf(childRule);
pos.unshift(index);
} else if (childRule.parentStyleSheet) {
const rules = Array.from(childRule.parentStyleSheet.cssRules);
const index = rules.indexOf(childRule);
pos.unshift(index);
}
return pos;
}
return recurse(rule, positions);
}
/**
* For StyleSheets in Element, this function retrieves id of its host element.
* For adopted StyleSheets, this function retrieves its styleId from a styleMirror.
*/
function getIdAndStyleId(
sheet: CSSStyleSheet | undefined | null,
mirror: Mirror,
styleMirror: StyleSheetMirror,
): {
styleId?: number;
id?: number;
} {
let id, styleId;
if (!sheet) return {};
if (sheet.ownerNode) id = mirror.getId(sheet.ownerNode as Node);
else styleId = styleMirror.getId(sheet);
return {
styleId,
id,
};
}
function initStyleSheetObserver(
{ styleSheetRuleCb, mirror, stylesheetManager }: observerParam,
{ win }: { win: IWindow },
): listenerHandler {
if (!win.CSSStyleSheet || !win.CSSStyleSheet.prototype) {
// If, for whatever reason, CSSStyleSheet is not available, we skip the observation of stylesheets.
return () => {
// Do nothing
};
}
// eslint-disable-next-line @typescript-eslint/unbound-method
const insertRule = win.CSSStyleSheet.prototype.insertRule;
win.CSSStyleSheet.prototype.insertRule = function (
this: CSSStyleSheet,
rule: string,
index?: number,
) {
const { id, styleId } = getIdAndStyleId(
this,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
adds: [{ rule, index }],
});
}
return insertRule.apply(this, [rule, index]);
};
// eslint-disable-next-line @typescript-eslint/unbound-method
const deleteRule = win.CSSStyleSheet.prototype.deleteRule;
win.CSSStyleSheet.prototype.deleteRule = function (
this: CSSStyleSheet,
index: number,
) {
const { id, styleId } = getIdAndStyleId(
this,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
removes: [{ index }],
});
}
return deleteRule.apply(this, [index]);
};
let replace: (text: string) => Promise<CSSStyleSheet>;
if (win.CSSStyleSheet.prototype.replace) {
// eslint-disable-next-line @typescript-eslint/unbound-method
replace = win.CSSStyleSheet.prototype.replace;
win.CSSStyleSheet.prototype.replace = function (
this: CSSStyleSheet,
text: string,
) {
const { id, styleId } = getIdAndStyleId(
this,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
replace: text,
});
}
return replace.apply(this, [text]);
};
}
let replaceSync: (text: string) => void;
if (win.CSSStyleSheet.prototype.replaceSync) {
// eslint-disable-next-line @typescript-eslint/unbound-method
replaceSync = win.CSSStyleSheet.prototype.replaceSync;
win.CSSStyleSheet.prototype.replaceSync = function (
this: CSSStyleSheet,
text: string,
) {
const { id, styleId } = getIdAndStyleId(
this,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
replaceSync: text,
});
}
return replaceSync.apply(this, [text]);
};
}
const supportedNestedCSSRuleTypes: {
[key: string]: GroupingCSSRuleTypes;
} = {};
if (canMonkeyPatchNestedCSSRule('CSSGroupingRule')) {
supportedNestedCSSRuleTypes.CSSGroupingRule = win.CSSGroupingRule;
} else {
// Some browsers (Safari) don't support CSSGroupingRule
// https://caniuse.com/?search=cssgroupingrule
// fall back to monkey patching classes that would have inherited from CSSGroupingRule
if (canMonkeyPatchNestedCSSRule('CSSMediaRule')) {
supportedNestedCSSRuleTypes.CSSMediaRule = win.CSSMediaRule;
}
if (canMonkeyPatchNestedCSSRule('CSSConditionRule')) {
supportedNestedCSSRuleTypes.CSSConditionRule = win.CSSConditionRule;
}
if (canMonkeyPatchNestedCSSRule('CSSSupportsRule')) {
supportedNestedCSSRuleTypes.CSSSupportsRule = win.CSSSupportsRule;
}
}
const unmodifiedFunctions: {
[key: string]: {
insertRule: (rule: string, index?: number) => number;
deleteRule: (index: number) => void;
};
} = {};
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
unmodifiedFunctions[typeKey] = {
// eslint-disable-next-line @typescript-eslint/unbound-method
insertRule: type.prototype.insertRule,
// eslint-disable-next-line @typescript-eslint/unbound-method
deleteRule: type.prototype.deleteRule,
};
type.prototype.insertRule = function (
this: CSSGroupingRule,
rule: string,
index?: number,
) {
const { id, styleId } = getIdAndStyleId(
this.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
adds: [
{
rule,
index: [
...getNestedCSSRulePositions(this as CSSRule),
index || 0, // defaults to 0
],
},
],
});
}
return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]);
};
type.prototype.deleteRule = function (
this: CSSGroupingRule,
index: number,
) {
const { id, styleId } = getIdAndStyleId(
this.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
removes: [
{ index: [...getNestedCSSRulePositions(this as CSSRule), index] },
],
});
}
return unmodifiedFunctions[typeKey].deleteRule.apply(this, [index]);
};
});
return () => {
win.CSSStyleSheet.prototype.insertRule = insertRule;
win.CSSStyleSheet.prototype.deleteRule = deleteRule;
replace && (win.CSSStyleSheet.prototype.replace = replace);
replaceSync && (win.CSSStyleSheet.prototype.replaceSync = replaceSync);
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
});
};
}
export function initAdoptedStyleSheetObserver(
{
mirror,
stylesheetManager,
}: Pick<observerParam, 'mirror' | 'stylesheetManager'>,
host: Document | ShadowRoot,
): listenerHandler {
let hostId: number | null = null;
// host of adoptedStyleSheets is outermost document or IFrame's document
if (host.nodeName === '#document') hostId = mirror.getId(host);
// The host is a ShadowRoot.
else hostId = mirror.getId((host as ShadowRoot).host);
const patchTarget =
host.nodeName === '#document'
? (host as Document).defaultView?.Document
: host.ownerDocument?.defaultView?.ShadowRoot;
const originalPropertyDescriptor = Object.getOwnPropertyDescriptor(
patchTarget?.prototype,
'adoptedStyleSheets',
);
if (
hostId === null ||
hostId === -1 ||
!patchTarget ||
!originalPropertyDescriptor
)
return () => {
//
};
// Patch adoptedStyleSheets by overriding the original one.
Object.defineProperty(host, 'adoptedStyleSheets', {
configurable: originalPropertyDescriptor.configurable,
enumerable: originalPropertyDescriptor.enumerable,
get(): CSSStyleSheet[] {
return originalPropertyDescriptor.get?.call(this) as CSSStyleSheet[];
},
set(sheets: CSSStyleSheet[]) {
const result = originalPropertyDescriptor.set?.call(this, sheets);
if (hostId !== null && hostId !== -1) {
try {
stylesheetManager.adoptStyleSheets(sheets, hostId);
} catch (e) {
// for safety
}
}
return result;
},
});
return () => {
Object.defineProperty(host, 'adoptedStyleSheets', {
configurable: originalPropertyDescriptor.configurable,
enumerable: originalPropertyDescriptor.enumerable,
// eslint-disable-next-line @typescript-eslint/unbound-method
get: originalPropertyDescriptor.get,
// eslint-disable-next-line @typescript-eslint/unbound-method
set: originalPropertyDescriptor.set,
});
};
}
function initStyleDeclarationObserver(
{
styleDeclarationCb,
mirror,
ignoreCSSAttributes,
stylesheetManager,
}: observerParam,
{ win }: { win: IWindow },
): listenerHandler {
// eslint-disable-next-line @typescript-eslint/unbound-method
const setProperty = win.CSSStyleDeclaration.prototype.setProperty;
win.CSSStyleDeclaration.prototype.setProperty = function (
this: CSSStyleDeclaration,
property: string,
value: string,
priority: string,
) {
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return setProperty.apply(this, [property, value, priority]);
}
const { id, styleId } = getIdAndStyleId(
this.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleDeclarationCb({
id,
styleId,
set: {
property,
value,
priority,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(this.parentRule!),
});
}
return setProperty.apply(this, [property, value, priority]);
};
// eslint-disable-next-line @typescript-eslint/unbound-method
const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty;
win.CSSStyleDeclaration.prototype.removeProperty = function (
this: CSSStyleDeclaration,
property: string,
) {
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return removeProperty.apply(this, [property]);
}
const { id, styleId } = getIdAndStyleId(
this.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleDeclarationCb({
id,
styleId,
remove: {
property,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(this.parentRule!),
});
}
return removeProperty.apply(this, [property]);
};
return () => {
win.CSSStyleDeclaration.prototype.setProperty = setProperty;
win.CSSStyleDeclaration.prototype.removeProperty = removeProperty;
};
}
function initMediaInteractionObserver({
mediaInteractionCb,
blockClass,
blockSelector,
mirror,
sampling,
}: observerParam): listenerHandler {
const handler = (type: MediaInteractions) =>
throttle((event: Event) => {
const target = getEventTarget(event);
if (
!target ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
const { currentTime, volume, muted, playbackRate } =
target as HTMLMediaElement;
mediaInteractionCb({
type,
id: mirror.getId(target as Node),
currentTime,
volume,
muted,
playbackRate,
});
}, sampling.media || 500);
const handlers = [
on('play', handler(MediaInteractions.Play)),
on('pause', handler(MediaInteractions.Pause)),
on('seeked', handler(MediaInteractions.Seeked)),
on('volumechange', handler(MediaInteractions.VolumeChange)),
on('ratechange', handler(MediaInteractions.RateChange)),
];
return () => {
handlers.forEach((h) => h());
};
}
function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
const win = doc.defaultView as IWindow;
if (!win) {
return () => {
//
};
}
const handlers: listenerHandler[] = [];
const fontMap = new WeakMap<FontFace, fontParam>();
const originalFontFace = win.FontFace;
win.FontFace = function FontFace(
family: string,
source: string | ArrayBufferLike,
descriptors?: FontFaceDescriptors,
) {
const fontFace = new originalFontFace(family, source, descriptors);
fontMap.set(fontFace, {
family,
buffer: typeof source !== 'string',
descriptors,
fontSource:
typeof source === 'string'
? source
: JSON.stringify(Array.from(new Uint8Array(source))),
});
return fontFace;
} as unknown as typeof FontFace;
const restoreHandler = patch(
doc.fonts,
'add',
function (original: (font: FontFace) => void) {
return function (this: FontFaceSet, fontFace: FontFace) {
setTimeout(() => {
const p = fontMap.get(fontFace);
if (p) {
fontCb(p);
fontMap.delete(fontFace);
}
}, 0);
return original.apply(this, [fontFace]);
};
},
);
handlers.push(() => {
win.FontFace = originalFontFace;
});
handlers.push(restoreHandler);
return () => {
handlers.forEach((h) => h());
};
}
function initSelectionObserver(param: observerParam): listenerHandler {
const { doc, mirror, blockClass, blockSelector, selectionCb } = param;
let collapsed = true;
const updateSelection = () => {
const selection = doc.getSelection();
if (!selection || (collapsed && selection?.isCollapsed)) return;
collapsed = selection.isCollapsed || false;
const ranges: SelectionRange[] = [];
const count = selection.rangeCount || 0;
for (let i = 0; i < count; i++) {
const range = selection.getRangeAt(i);
const { startContainer, startOffset, endContainer, endOffset } = range;
const blocked =
isBlocked(startContainer, blockClass, blockSelector, true) ||
isBlocked(endContainer, blockClass, blockSelector, true);
if (blocked) continue;
ranges.push({
start: mirror.getId(startContainer),
startOffset,
end: mirror.getId(endContainer),
endOffset,
});
}
selectionCb({ ranges });
};
updateSelection();
return on('selectionchange', updateSelection);
}
function mergeHooks(o: observerParam, hooks: hooksParam) {
const {
mutationCb,
mousemoveCb,
mouseInteractionCb,
scrollCb,
viewportResizeCb,
inputCb,
mediaInteractionCb,
styleSheetRuleCb,
styleDeclarationCb,
canvasMutationCb,
fontCb,
selectionCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
hooks.mutation(...p);
}
mutationCb(...p);
};
o.mousemoveCb = (...p: Arguments<mousemoveCallBack>) => {
if (hooks.mousemove) {
hooks.mousemove(...p);
}
mousemoveCb(...p);
};
o.mouseInteractionCb = (...p: Arguments<mouseInteractionCallBack>) => {
if (hooks.mouseInteraction) {
hooks.mouseInteraction(...p);
}
mouseInteractionCb(...p);
};
o.scrollCb = (...p: Arguments<scrollCallback>) => {
if (hooks.scroll) {
hooks.scroll(...p);
}
scrollCb(...p);
};
o.viewportResizeCb = (...p: Arguments<viewportResizeCallback>) => {
if (hooks.viewportResize) {
hooks.viewportResize(...p);
}
viewportResizeCb(...p);
};
o.inputCb = (...p: Arguments<inputCallback>) => {
if (hooks.input) {
hooks.input(...p);
}
inputCb(...p);
};
o.mediaInteractionCb = (...p: Arguments<mediaInteractionCallback>) => {
if (hooks.mediaInteaction) {
hooks.mediaInteaction(...p);
}
mediaInteractionCb(...p);
};
o.styleSheetRuleCb = (...p: Arguments<styleSheetRuleCallback>) => {
if (hooks.styleSheetRule) {
hooks.styleSheetRule(...p);
}
styleSheetRuleCb(...p);
};
o.styleDeclarationCb = (...p: Arguments<styleDeclarationCallback>) => {
if (hooks.styleDeclaration) {
hooks.styleDeclaration(...p);
}
styleDeclarationCb(...p);
};
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
if (hooks.canvasMutation) {
hooks.canvasMutation(...p);
}
canvasMutationCb(...p);
};
o.fontCb = (...p: Arguments<fontCallback>) => {
if (hooks.font) {
hooks.font(...p);
}
fontCb(...p);
};
o.selectionCb = (...p: Arguments<selectionCallback>) => {
if (hooks.selection) {
hooks.selection(...p);
}
selectionCb(...p);
};
}
export function initObservers(
o: observerParam,
hooks: hooksParam = {},
): listenerHandler {
const currentWindow = o.doc.defaultView; // basically document.window
if (!currentWindow) {
return () => {
//
};
}
mergeHooks(o, hooks);
const mutationObserver = initMutationObserver(o, o.doc);
const mousemoveHandler = initMoveObserver(o);
const mouseInteractionHandler = initMouseInteractionObserver(o);
const scrollHandler = initScrollObserver(o);
const viewportResizeHandler = initViewportResizeObserver(o);
const inputHandler = initInputObserver(o);
const mediaInteractionHandler = initMediaInteractionObserver(o);
const styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow });
const adoptedStyleSheetObserver = initAdoptedStyleSheetObserver(o, o.doc);
const styleDeclarationObserver = initStyleDeclarationObserver(o, {
win: currentWindow,
});
const fontObserver = o.collectFonts
? initFontObserver(o)
: () => {
//
};
const selectionObserver = initSelectionObserver(o);
// plugins
const pluginHandlers: listenerHandler[] = [];
for (const plugin of o.plugins) {
pluginHandlers.push(
plugin.observer(plugin.callback, currentWindow, plugin.options),
);
}
return () => {
mutationBuffers.forEach((b) => b.reset());
mutationObserver.disconnect();
mousemoveHandler();
mouseInteractionHandler();
scrollHandler();
viewportResizeHandler();
inputHandler();
mediaInteractionHandler();
styleSheetObserver();
adoptedStyleSheetObserver();
styleDeclarationObserver();
fontObserver();
selectionObserver();
pluginHandlers.forEach((h) => h());
};
}
type CSSGroupingProp =
| 'CSSGroupingRule'
| 'CSSMediaRule'
| 'CSSSupportsRule'
| 'CSSConditionRule';
function hasNestedCSSRule(prop: CSSGroupingProp): boolean {
return typeof window[prop] !== 'undefined';
}
function canMonkeyPatchNestedCSSRule(prop: CSSGroupingProp): boolean {
return Boolean(
typeof window[prop] !== 'undefined' &&
// Note: Generally, this check _shouldn't_ be necessary
// However, in some scenarios (e.g. jsdom) this can sometimes fail, so we check for it here
window[prop].prototype &&
'insertRule' in window[prop].prototype &&
'deleteRule' in window[prop].prototype,
);
}