Files
rrweb/packages/rrweb/src/record/observer.ts
Justin Halsall f6543be763 Full overhawl of video & audio playback to make it more complete (#1432)
* Add support for capturing media attributes in rrweb-snapshot

* Add loop to mediaInteractionParam

* Add support for loop in RRMediaElement

* Add support for recording loop attribute on media elements

* Update video playback and fix bugs

* Update cross-origin iframe media attributes and player state
2026-04-01 12:00:00 +08:00

1401 lines
40 KiB
TypeScript

import {
MaskInputOptions,
maskInputValue,
Mirror,
getInputType,
toLowerCase,
} from 'rrweb-snapshot';
import type { FontFaceSet } from 'css-font-loading-module';
import {
throttle,
on,
hookSetter,
getWindowScroll,
getWindowHeight,
getWindowWidth,
isBlocked,
legacy_isTouchEvent,
patch,
StyleSheetMirror,
nowTimestamp,
} from '../utils';
import type { observerParam, MutationBufferParam } from '../types';
import {
mutationCallBack,
mousemoveCallBack,
mousePosition,
mouseInteractionCallBack,
MouseInteractions,
PointerTypes,
listenerHandler,
scrollCallback,
styleSheetRuleCallback,
viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
IncrementalSource,
hooksParam,
Arguments,
mediaInteractionCallback,
MediaInteractions,
canvasMutationCallback,
fontCallback,
fontParam,
styleDeclarationCallback,
IWindow,
SelectionRange,
selectionCallback,
customElementCallback,
} from '@rrweb/types';
import MutationBuffer from './mutation';
import { callbackWrapper } from './error-handler';
type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
};
type WindowWithAngularZone = IWindow & {
Zone?: {
__symbol__?: (key: string) => string;
};
};
export const mutationBuffers: MutationBuffer[] = [];
// 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];
}
} catch {
// fallback to `event.target` below
}
return event && 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)(
callbackWrapper(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(
callbackWrapper(
(
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 = callbackWrapper(
throttle<MouseEvent | TouchEvent | DragEvent>(
callbackWrapper((evt) => {
const target = getEventTarget(evt);
// 'legacy' here as we could switch to https://developer.mozilla.org/en-US/docs/Web/API/Element/pointermove_event
const { clientX, clientY } = legacy_isTouchEvent(evt)
? evt.changedTouches[0]
: evt;
if (!timeBaseline) {
timeBaseline = nowTimestamp();
}
positions.push({
x: clientX,
y: clientY,
id: mirror.getId(target as Node),
timeOffset: nowTimestamp() - 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 callbackWrapper(() => {
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[] = [];
let currentPointerType: PointerTypes | null = null;
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent | PointerEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target, blockClass, blockSelector, true)) {
return;
}
let pointerType: PointerTypes | null = null;
let thisEventKey = eventKey;
if ('pointerType' in event) {
switch (event.pointerType) {
case 'mouse':
pointerType = PointerTypes.Mouse;
break;
case 'touch':
pointerType = PointerTypes.Touch;
break;
case 'pen':
pointerType = PointerTypes.Pen;
break;
}
if (pointerType === PointerTypes.Touch) {
if (MouseInteractions[eventKey] === MouseInteractions.MouseDown) {
// we are actually listening on 'pointerdown'
thisEventKey = 'TouchStart';
} else if (
MouseInteractions[eventKey] === MouseInteractions.MouseUp
) {
// we are actually listening on 'pointerup'
thisEventKey = 'TouchEnd';
}
} else if (pointerType === PointerTypes.Pen) {
// TODO: these will get incorrectly emitted as MouseDown/MouseUp
}
} else if (legacy_isTouchEvent(event)) {
pointerType = PointerTypes.Touch;
}
if (pointerType !== null) {
currentPointerType = pointerType;
if (
(thisEventKey.startsWith('Touch') &&
pointerType === PointerTypes.Touch) ||
(thisEventKey.startsWith('Mouse') &&
pointerType === PointerTypes.Mouse)
) {
// don't output redundant info
pointerType = null;
}
} else if (MouseInteractions[eventKey] === MouseInteractions.Click) {
pointerType = currentPointerType;
currentPointerType = null; // cleanup as we've used it
}
const e = legacy_isTouchEvent(event) ? event.changedTouches[0] : event;
if (!e) {
return;
}
const id = mirror.getId(target);
const { clientX, clientY } = e;
callbackWrapper(mouseInteractionCb)({
type: MouseInteractions[thisEventKey],
id,
x: clientX,
y: clientY,
...(pointerType !== null && { pointerType }),
});
};
};
Object.keys(MouseInteractions)
.filter(
(key) =>
Number.isNaN(Number(key)) &&
!key.endsWith('_Departed') &&
disableMap[key] !== false,
)
.forEach((eventKey: keyof typeof MouseInteractions) => {
let eventName = toLowerCase(eventKey);
const handler = getHandler(eventKey);
if (window.PointerEvent) {
switch (MouseInteractions[eventKey]) {
case MouseInteractions.MouseDown:
case MouseInteractions.MouseUp:
eventName = eventName.replace(
'mouse',
'pointer',
) as unknown as typeof eventName;
break;
case MouseInteractions.TouchStart:
case MouseInteractions.TouchEnd:
// these are handled by pointerdown/pointerup
return;
}
}
handlers.push(on(eventName, handler, doc));
});
return callbackWrapper(() => {
handlers.forEach((h) => h());
});
}
export function initScrollObserver({
scrollCb,
doc,
mirror,
blockClass,
blockSelector,
sampling,
}: Pick<
observerParam,
'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling'
>): listenerHandler {
const updatePosition = callbackWrapper(
throttle<UIEvent>(
callbackWrapper((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,
{ win }: { win: IWindow },
): listenerHandler {
let lastH = -1;
let lastW = -1;
const updateDimension = callbackWrapper(
throttle(
callbackWrapper(() => {
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, win);
}
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver({
inputCb,
doc,
mirror,
blockClass,
blockSelector,
ignoreClass,
ignoreSelector,
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) ||
(ignoreSelector && target.matches(ignoreSelector))
) {
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({
element: target,
maskInputOptions,
tagName,
type,
value: text,
maskInputFn,
});
}
cbWithDedup(
target,
userTriggeredOnInput
? { text, isChecked, userTriggered }
: { 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) {
doc
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => {
if (el !== target) {
const text = (el as HTMLInputElement).value;
cbWithDedup(
el,
userTriggeredOnInput
? { text, isChecked: !isChecked, userTriggered: false }
: { text, 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 Node);
callbackWrapper(inputCb)({
...v,
id,
});
}
}
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<listenerHandler | hookResetter> = events.map(
(eventName) => on(eventName, callbackWrapper(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
callbackWrapper(eventHandler)({
target: this as EventTarget,
isTrusted: false, // userTriggered to false as this could well be programmatic
} as Event);
},
},
false,
currentWindow,
),
),
);
}
return callbackWrapper(() => {
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 = new Proxy(insertRule, {
apply: callbackWrapper(
(
target: typeof insertRule,
thisArg: CSSStyleSheet,
argumentsList: [string, number | undefined],
) => {
const [rule, index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
adds: [{ rule, index }],
});
}
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method
const deleteRule = win.CSSStyleSheet.prototype.deleteRule;
win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, {
apply: callbackWrapper(
(
target: typeof deleteRule,
thisArg: CSSStyleSheet,
argumentsList: [number],
) => {
const [index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
removes: [{ index }],
});
}
return target.apply(thisArg, argumentsList);
},
),
});
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 = new Proxy(replace, {
apply: callbackWrapper(
(
target: typeof replace,
thisArg: CSSStyleSheet,
argumentsList: [string],
) => {
const [text] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
replace: text,
});
}
return target.apply(thisArg, argumentsList);
},
),
});
}
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 = new Proxy(replaceSync, {
apply: callbackWrapper(
(
target: typeof replaceSync,
thisArg: CSSStyleSheet,
argumentsList: [string],
) => {
const [text] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
replaceSync: text,
});
}
return target.apply(thisArg, argumentsList);
},
),
});
}
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 = new Proxy(
unmodifiedFunctions[typeKey].insertRule,
{
apply: callbackWrapper(
(
target: typeof insertRule,
thisArg: CSSRule,
argumentsList: [string, number | undefined],
) => {
const [rule, index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
adds: [
{
rule,
index: [
...getNestedCSSRulePositions(thisArg),
index || 0, // defaults to 0
],
},
],
});
}
return target.apply(thisArg, argumentsList);
},
),
},
);
type.prototype.deleteRule = new Proxy(
unmodifiedFunctions[typeKey].deleteRule,
{
apply: callbackWrapper(
(
target: typeof deleteRule,
thisArg: CSSRule,
argumentsList: [number],
) => {
const [index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
thisArg.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
removes: [
{ index: [...getNestedCSSRulePositions(thisArg), index] },
],
});
}
return target.apply(thisArg, argumentsList);
},
),
},
);
});
return callbackWrapper(() => {
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 = patchTarget?.prototype
? Object.getOwnPropertyDescriptor(
patchTarget?.prototype,
'adoptedStyleSheets',
)
: undefined;
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 callbackWrapper(() => {
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 = new Proxy(setProperty, {
apply: callbackWrapper(
(
target: typeof setProperty,
thisArg: CSSStyleDeclaration,
argumentsList: [string, string, string],
) => {
const [property, value, priority] = argumentsList;
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return setProperty.apply(thisArg, [property, value, priority]);
}
const { id, styleId } = getIdAndStyleId(
thisArg.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(thisArg.parentRule!),
});
}
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method
const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty;
win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, {
apply: callbackWrapper(
(
target: typeof removeProperty,
thisArg: CSSStyleDeclaration,
argumentsList: [string],
) => {
const [property] = argumentsList;
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return removeProperty.apply(thisArg, [property]);
}
const { id, styleId } = getIdAndStyleId(
thisArg.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(thisArg.parentRule!),
});
}
return target.apply(thisArg, argumentsList);
},
),
});
return callbackWrapper(() => {
win.CSSStyleDeclaration.prototype.setProperty = setProperty;
win.CSSStyleDeclaration.prototype.removeProperty = removeProperty;
});
}
function initMediaInteractionObserver({
mediaInteractionCb,
blockClass,
blockSelector,
mirror,
sampling,
doc,
}: observerParam): listenerHandler {
const handler = callbackWrapper((type: MediaInteractions) =>
throttle(
callbackWrapper((event: Event) => {
const target = getEventTarget(event);
if (
!target ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
const { currentTime, volume, muted, playbackRate, loop } =
target as HTMLMediaElement;
mediaInteractionCb({
type,
id: mirror.getId(target as Node),
currentTime,
volume,
muted,
playbackRate,
loop,
});
}),
sampling.media || 500,
),
);
const handlers = [
on('play', handler(MediaInteractions.Play), doc),
on('pause', handler(MediaInteractions.Pause), doc),
on('seeked', handler(MediaInteractions.Seeked), doc),
on('volumechange', handler(MediaInteractions.VolumeChange), doc),
on('ratechange', handler(MediaInteractions.RateChange), doc),
];
return callbackWrapper(() => {
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(
callbackWrapper(() => {
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 callbackWrapper(() => {
handlers.forEach((h) => h());
});
}
function initSelectionObserver(param: observerParam): listenerHandler {
const { doc, mirror, blockClass, blockSelector, selectionCb } = param;
let collapsed = true;
const updateSelection = callbackWrapper(() => {
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 initCustomElementObserver({
doc,
customElementCb,
}: observerParam): listenerHandler {
const win = doc.defaultView as IWindow;
// eslint-disable-next-line @typescript-eslint/no-empty-function
if (!win || !win.customElements) return () => {};
const restoreHandler = patch(
win.customElements,
'define',
function (
original: (
name: string,
constructor: CustomElementConstructor,
options?: ElementDefinitionOptions,
) => void,
) {
return function (
name: string,
constructor: CustomElementConstructor,
options?: ElementDefinitionOptions,
) {
try {
customElementCb({
define: {
name,
},
});
} catch (e) {
console.warn(`Custom element callback failed for ${name}`);
}
return original.apply(this, [name, constructor, options]);
};
},
);
return restoreHandler;
}
function mergeHooks(o: observerParam, hooks: hooksParam) {
const {
mutationCb,
mousemoveCb,
mouseInteractionCb,
scrollCb,
viewportResizeCb,
inputCb,
mediaInteractionCb,
styleSheetRuleCb,
styleDeclarationCb,
canvasMutationCb,
fontCb,
selectionCb,
customElementCb,
} = 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);
};
o.customElementCb = (...c: Arguments<customElementCallback>) => {
if (hooks.customElement) {
hooks.customElement(...c);
}
customElementCb(...c);
};
}
export function initObservers(
o: observerParam,
hooks: hooksParam = {},
): listenerHandler {
const currentWindow = o.doc.defaultView; // basically document.window
if (!currentWindow) {
return () => {
//
};
}
mergeHooks(o, hooks);
let mutationObserver: MutationObserver | undefined;
if (o.recordDOM) {
mutationObserver = initMutationObserver(o, o.doc);
}
const mousemoveHandler = initMoveObserver(o);
const mouseInteractionHandler = initMouseInteractionObserver(o);
const scrollHandler = initScrollObserver(o);
const viewportResizeHandler = initViewportResizeObserver(o, {
win: currentWindow,
});
const inputHandler = initInputObserver(o);
const mediaInteractionHandler = initMediaInteractionObserver(o);
// eslint-disable-next-line @typescript-eslint/no-empty-function
let styleSheetObserver = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
let adoptedStyleSheetObserver = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
let styleDeclarationObserver = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
let fontObserver = () => {};
if (o.recordDOM) {
styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow });
adoptedStyleSheetObserver = initAdoptedStyleSheetObserver(o, o.doc);
styleDeclarationObserver = initStyleDeclarationObserver(o, {
win: currentWindow,
});
if (o.collectFonts) {
fontObserver = initFontObserver(o);
}
}
const selectionObserver = initSelectionObserver(o);
const customElementObserver = initCustomElementObserver(o);
// plugins
const pluginHandlers: listenerHandler[] = [];
for (const plugin of o.plugins) {
pluginHandlers.push(
plugin.observer(plugin.callback, currentWindow, plugin.options),
);
}
return callbackWrapper(() => {
mutationBuffers.forEach((b) => b.reset());
mutationObserver?.disconnect();
mousemoveHandler();
mouseInteractionHandler();
scrollHandler();
viewportResizeHandler();
inputHandler();
mediaInteractionHandler();
styleSheetObserver();
adoptedStyleSheetObserver();
styleDeclarationObserver();
fontObserver();
selectionObserver();
customElementObserver();
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,
);
}