385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import { INode, serializeNodeWithId } from 'rrweb-snapshot';
|
|
import {
|
|
mirror,
|
|
throttle,
|
|
on,
|
|
hookSetter,
|
|
getWindowHeight,
|
|
getWindowWidth,
|
|
} from '../utils';
|
|
import {
|
|
mutationCallBack,
|
|
removedNodeMutation,
|
|
addedNodeMutation,
|
|
observerParam,
|
|
mousemoveCallBack,
|
|
mousePosition,
|
|
mouseInteractionCallBack,
|
|
MouseInteractions,
|
|
listenerHandler,
|
|
scrollCallback,
|
|
viewportResizeCallback,
|
|
inputValue,
|
|
inputCallback,
|
|
hookResetter,
|
|
textCursor,
|
|
attributeCursor,
|
|
} from '../types';
|
|
|
|
/**
|
|
* Mutation observer will merge several mutations into an array and pass
|
|
* it to the callback function, this may make tracing added nodes hard.
|
|
* For example, if we append an element el_1 into body, and then append
|
|
* another element el_2 into el_1, these two mutations may be passed to the
|
|
* callback function together when the two operations were done.
|
|
* Generally we need trace child nodes of newly added node, but in this
|
|
* case if we count el_2 as el_1's child node in the first mutation record,
|
|
* then we will count el_2 again in the secoond mutation record which was
|
|
* duplicated.
|
|
* To avoid of duplicate counting added nodes, we will use a Set to store
|
|
* added nodes and its child nodes during iterate mutation records. Then
|
|
* collect added nodes from the Set which will has no duplicate copy. But
|
|
* this also cause newly added node will not be serialized with id ASAP,
|
|
* which means all the id related calculation should be lazy too.
|
|
* @param cb mutationCallBack
|
|
*/
|
|
function initMutationObserver(cb: mutationCallBack): MutationObserver {
|
|
const observer = new MutationObserver(mutations => {
|
|
const texts: textCursor[] = [];
|
|
const attributes: attributeCursor[] = [];
|
|
let removes: removedNodeMutation[] = [];
|
|
const adds: addedNodeMutation[] = [];
|
|
const dropped: Node[] = [];
|
|
|
|
const addsSet = new Set<Node>();
|
|
const genAdds = (n: Node) => {
|
|
addsSet.add(n);
|
|
n.childNodes.forEach(childN => genAdds(childN));
|
|
};
|
|
mutations.forEach(mutation => {
|
|
const {
|
|
type,
|
|
target,
|
|
oldValue,
|
|
addedNodes,
|
|
removedNodes,
|
|
attributeName,
|
|
} = mutation;
|
|
switch (type) {
|
|
case 'characterData': {
|
|
const value = target.textContent;
|
|
if (value !== oldValue) {
|
|
texts.push({
|
|
value,
|
|
node: target,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'attributes': {
|
|
const value = (target as HTMLElement).getAttribute(attributeName!);
|
|
if (value === oldValue) {
|
|
return;
|
|
}
|
|
let item: attributeCursor | undefined = attributes.find(
|
|
a => a.node === target,
|
|
);
|
|
if (!item) {
|
|
item = {
|
|
node: target,
|
|
attributes: {},
|
|
};
|
|
attributes.push(item);
|
|
}
|
|
// overwrite attribute if the mutations was triggered in same time
|
|
item.attributes[attributeName!] = value;
|
|
}
|
|
case 'childList': {
|
|
addedNodes.forEach(n => genAdds(n));
|
|
removedNodes.forEach(n => {
|
|
// removed node has not been serialized yet, just remove it from the Set
|
|
if (addsSet.has(n)) {
|
|
addsSet.delete(n);
|
|
dropped.push(n);
|
|
} else if (addsSet.has(target) && !mirror.getId(n as INode)) {
|
|
/**
|
|
* If target was newly added and removed child node was
|
|
* not serialized, it means the child node has been removed
|
|
* before callback fired, so we can ignore it.
|
|
* TODO: verify this
|
|
*/
|
|
} else {
|
|
removes.push({
|
|
parentId: mirror.getId(target as INode),
|
|
id: mirror.getId(n as INode),
|
|
});
|
|
}
|
|
mirror.removeNodeFromMap(n as INode);
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
removes = removes.map(remove => {
|
|
if (remove.parentNode) {
|
|
remove.parentId = mirror.getId(remove.parentNode as INode);
|
|
delete remove.parentNode;
|
|
}
|
|
return remove;
|
|
});
|
|
|
|
Array.from(addsSet).forEach(n => {
|
|
const parentId = mirror.getId(n.parentNode as INode);
|
|
if (
|
|
parentId &&
|
|
!dropped.some(d => d === n.parentNode) &&
|
|
!removes.some(r => r.id === parentId)
|
|
) {
|
|
adds.push({
|
|
parentId: mirror.getId(n.parentNode as INode),
|
|
previousId: !n.previousSibling
|
|
? n.previousSibling
|
|
: mirror.getId(n.previousSibling as INode),
|
|
nextId: !n.nextSibling
|
|
? n.nextSibling
|
|
: mirror.getId(n.nextSibling as INode),
|
|
node: serializeNodeWithId(n, document, mirror.map, true)!,
|
|
});
|
|
} else {
|
|
dropped.push(n);
|
|
}
|
|
});
|
|
|
|
cb({
|
|
texts: texts
|
|
.map(text => ({
|
|
id: mirror.getId(text.node as INode),
|
|
value: text.value,
|
|
}))
|
|
// text mutation without ID means the target node has been removed
|
|
.filter(text => text.id),
|
|
attributes: attributes
|
|
.map(attribute => ({
|
|
id: mirror.getId(attribute.node as INode),
|
|
attributes: attribute.attributes,
|
|
}))
|
|
// attribute mutation without ID means the target node has been removed
|
|
.filter(attribute => attribute.id),
|
|
removes,
|
|
adds,
|
|
});
|
|
});
|
|
observer.observe(document, {
|
|
attributes: true,
|
|
attributeOldValue: true,
|
|
characterData: true,
|
|
characterDataOldValue: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
return observer;
|
|
}
|
|
|
|
function initMousemoveObserver(cb: mousemoveCallBack): listenerHandler {
|
|
let positions: mousePosition[] = [];
|
|
let timeBaseline: number | null;
|
|
const wrappedCb = throttle(() => {
|
|
const totalOffset = Date.now() - timeBaseline!;
|
|
cb(
|
|
positions.map(p => {
|
|
p.timeOffset -= totalOffset;
|
|
return p;
|
|
}),
|
|
);
|
|
positions = [];
|
|
timeBaseline = null;
|
|
}, 500);
|
|
const updatePosition = throttle<MouseEvent>(
|
|
evt => {
|
|
const { clientX, clientY } = evt;
|
|
if (!timeBaseline) {
|
|
timeBaseline = Date.now();
|
|
}
|
|
positions.push({
|
|
x: clientX,
|
|
y: clientY,
|
|
timeOffset: Date.now() - timeBaseline,
|
|
});
|
|
wrappedCb();
|
|
},
|
|
20,
|
|
{
|
|
trailing: false,
|
|
},
|
|
);
|
|
return on('mousemove', updatePosition);
|
|
}
|
|
|
|
function initMouseInteractionObserver(
|
|
cb: mouseInteractionCallBack,
|
|
): listenerHandler {
|
|
const handlers: listenerHandler[] = [];
|
|
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
|
|
return (event: MouseEvent) => {
|
|
const id = mirror.getId(event.target as INode);
|
|
const { clientX, clientY } = event;
|
|
cb({
|
|
type: MouseInteractions[eventKey],
|
|
id,
|
|
x: clientX,
|
|
y: clientY,
|
|
});
|
|
};
|
|
};
|
|
Object.keys(MouseInteractions)
|
|
.filter(key => Number.isNaN(Number(key)))
|
|
.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): listenerHandler {
|
|
const updatePosition = throttle<UIEvent>(evt => {
|
|
if (!evt.target) {
|
|
return;
|
|
}
|
|
const id = mirror.getId(evt.target as INode);
|
|
if (evt.target === document) {
|
|
cb({
|
|
id,
|
|
x: document.documentElement.scrollLeft,
|
|
y: document.documentElement.scrollTop,
|
|
});
|
|
} else {
|
|
cb({
|
|
id,
|
|
x: (evt.target as HTMLElement).scrollLeft,
|
|
y: (evt.target as HTMLElement).scrollTop,
|
|
});
|
|
}
|
|
}, 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);
|
|
}
|
|
|
|
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<EventTarget, inputValue> = 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<listenerHandler | hookResetter> = [
|
|
'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<HTMLElement>(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);
|
|
const mouseInteractionHandler = initMouseInteractionObserver(
|
|
o.mouseInteractionCb,
|
|
);
|
|
const scrollHandler = initScrollObserver(o.scrollCb);
|
|
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
|
|
const inputHandler = initInputObserver(o.inputCb);
|
|
return {
|
|
mutationObserver,
|
|
mousemoveHandler,
|
|
mouseInteractionHandler,
|
|
scrollHandler,
|
|
viewportResizeHandler,
|
|
inputHandler,
|
|
};
|
|
}
|