Move mutation processing into it's own class (#223)
* Move mutation processing into it's own object. This should stand on it's own as a refactor, but is intended as a basis for exposing the new MutationBuffer object to further outside control e.g. to 'mute' or batch up mutation emission when the page becomes inactive from a https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API point of view * The `processMutations` function needed to be bound to the `mutationBuffer` object, as otherwise `this` referred to the `MutationObserver` object itself * Neglected to add this output of `npm run typings` * Get around the binding problem by using Arrow function expressions * Prettier formatting
This commit is contained in:
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Some utils to handle the mutation observer DOM records.
|
|
||||||
* It should be more clear to extend the native data structure
|
|
||||||
* like Set and Map, but currently Typescript does not support
|
|
||||||
* that.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { INode } from 'rrweb-snapshot';
|
|
||||||
import { removedNodeMutation } from '../types';
|
|
||||||
import { mirror } from '../utils';
|
|
||||||
|
|
||||||
export function deepDelete(addsSet: Set<Node>, n: Node) {
|
|
||||||
addsSet.delete(n);
|
|
||||||
n.childNodes.forEach(childN => deepDelete(addsSet, childN));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isParentRemoved(
|
|
||||||
removes: removedNodeMutation[],
|
|
||||||
n: Node,
|
|
||||||
): boolean {
|
|
||||||
const { parentNode } = n;
|
|
||||||
if (!parentNode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const parentId = mirror.getId((parentNode as Node) as INode);
|
|
||||||
if (removes.some(r => r.id === parentId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isParentRemoved(removes, parentNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAncestorInSet(set: Set<Node>, n: Node): boolean {
|
|
||||||
const { parentNode } = n;
|
|
||||||
if (!parentNode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (set.has(parentNode)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isAncestorInSet(set, parentNode);
|
|
||||||
}
|
|
||||||
309
src/record/mutation.ts
Normal file
309
src/record/mutation.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { INode, serializeNodeWithId, transformAttribute } from 'rrweb-snapshot';
|
||||||
|
import {
|
||||||
|
mutationRecord,
|
||||||
|
blockClass,
|
||||||
|
mutationCallBack,
|
||||||
|
textCursor,
|
||||||
|
attributeCursor,
|
||||||
|
removedNodeMutation,
|
||||||
|
addedNodeMutation,
|
||||||
|
} from '../types';
|
||||||
|
import { mirror, isBlocked, isAncestorRemoved } from '../utils';
|
||||||
|
|
||||||
|
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
|
||||||
|
function isINode(n: Node | INode): n is INode {
|
||||||
|
return '__sn' in n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* controls behaviour of a MutationObserver
|
||||||
|
*/
|
||||||
|
export default class MutationBuffer {
|
||||||
|
private texts: textCursor[] = [];
|
||||||
|
private attributes: attributeCursor[] = [];
|
||||||
|
private removes: removedNodeMutation[] = [];
|
||||||
|
private adds: addedNodeMutation[] = [];
|
||||||
|
|
||||||
|
private movedMap: Record<string, true> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the browser MutationObserver emits multiple mutations after
|
||||||
|
* a delay for performance reasons, making tracing added nodes hard
|
||||||
|
* in our `processMutations` callback function.
|
||||||
|
* 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 to trace child nodes of newly added nodes, 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 second mutation record which was
|
||||||
|
* duplicated.
|
||||||
|
* To avoid of duplicate counting added nodes, we use a Set to store
|
||||||
|
* added nodes and its child nodes during iterate mutation records. Then
|
||||||
|
* collect added nodes from the Set which have no duplicate copy. But
|
||||||
|
* this also causes newly added nodes will not be serialized with id ASAP,
|
||||||
|
* which means all the id related calculation should be lazy too.
|
||||||
|
*/
|
||||||
|
private addedSet = new Set<Node>();
|
||||||
|
private movedSet = new Set<Node>();
|
||||||
|
private droppedSet = new Set<Node>();
|
||||||
|
|
||||||
|
private emissionCallback: mutationCallBack;
|
||||||
|
private blockClass: blockClass;
|
||||||
|
private inlineStylesheet: boolean;
|
||||||
|
private maskAllInputs: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cb: mutationCallBack,
|
||||||
|
blockClass: blockClass,
|
||||||
|
inlineStylesheet: boolean,
|
||||||
|
maskAllInputs: boolean,
|
||||||
|
) {
|
||||||
|
this.blockClass = blockClass;
|
||||||
|
this.inlineStylesheet = inlineStylesheet;
|
||||||
|
this.maskAllInputs = maskAllInputs;
|
||||||
|
this.emissionCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public processMutations = (mutations: mutationRecord[]) => {
|
||||||
|
mutations.forEach(this.processMutation);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sometimes child node may be pushed before its newly added
|
||||||
|
* parent, so we init a queue to store these nodes.
|
||||||
|
*/
|
||||||
|
const addQueue: Node[] = [];
|
||||||
|
const pushAdd = (n: Node) => {
|
||||||
|
const parentId = mirror.getId((n.parentNode as Node) as INode);
|
||||||
|
const nextId =
|
||||||
|
n.nextSibling && mirror.getId((n.nextSibling as unknown) as INode);
|
||||||
|
if (parentId === -1 || nextId === -1) {
|
||||||
|
return addQueue.push(n);
|
||||||
|
}
|
||||||
|
this.adds.push({
|
||||||
|
parentId,
|
||||||
|
nextId,
|
||||||
|
node: serializeNodeWithId(
|
||||||
|
n,
|
||||||
|
document,
|
||||||
|
mirror.map,
|
||||||
|
this.blockClass,
|
||||||
|
true,
|
||||||
|
this.inlineStylesheet,
|
||||||
|
this.maskAllInputs,
|
||||||
|
)!,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const n of this.movedSet) {
|
||||||
|
pushAdd(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of this.addedSet) {
|
||||||
|
if (
|
||||||
|
!isAncestorInSet(this.droppedSet, n) &&
|
||||||
|
!isParentRemoved(this.removes, n)
|
||||||
|
) {
|
||||||
|
pushAdd(n);
|
||||||
|
} else if (isAncestorInSet(this.movedSet, n)) {
|
||||||
|
pushAdd(n);
|
||||||
|
} else {
|
||||||
|
this.droppedSet.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (addQueue.length) {
|
||||||
|
if (
|
||||||
|
addQueue.every(
|
||||||
|
(n) => mirror.getId((n.parentNode as Node) as INode) === -1,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* If all nodes in queue could not find a serialized parent,
|
||||||
|
* it may be a bug or corner case. We need to escape the
|
||||||
|
* dead while loop at once.
|
||||||
|
*/
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pushAdd(addQueue.shift()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
private processMutation = (m: mutationRecord) => {
|
||||||
|
switch (m.type) {
|
||||||
|
case 'characterData': {
|
||||||
|
const value = m.target.textContent;
|
||||||
|
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
|
||||||
|
this.texts.push({
|
||||||
|
value,
|
||||||
|
node: m.target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'attributes': {
|
||||||
|
const value = (m.target as HTMLElement).getAttribute(m.attributeName!);
|
||||||
|
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let item: attributeCursor | undefined = this.attributes.find(
|
||||||
|
(a) => a.node === m.target,
|
||||||
|
);
|
||||||
|
if (!item) {
|
||||||
|
item = {
|
||||||
|
node: m.target,
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
this.attributes.push(item);
|
||||||
|
}
|
||||||
|
// overwrite attribute if the mutations was triggered in same time
|
||||||
|
item.attributes[m.attributeName!] = transformAttribute(
|
||||||
|
document,
|
||||||
|
m.attributeName!,
|
||||||
|
value!,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'childList': {
|
||||||
|
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
|
||||||
|
m.removedNodes.forEach((n) => {
|
||||||
|
const nodeId = mirror.getId(n as INode);
|
||||||
|
const parentId = mirror.getId(m.target as INode);
|
||||||
|
if (isBlocked(n, this.blockClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// removed node has not been serialized yet, just remove it from the Set
|
||||||
|
if (this.addedSet.has(n)) {
|
||||||
|
deepDelete(this.addedSet, n);
|
||||||
|
this.droppedSet.add(n);
|
||||||
|
} else if (this.addedSet.has(m.target) && nodeId === -1) {
|
||||||
|
/**
|
||||||
|
* 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 because
|
||||||
|
* newly added node will be serialized without child nodes.
|
||||||
|
* TODO: verify this
|
||||||
|
*/
|
||||||
|
} else if (isAncestorRemoved(m.target as INode)) {
|
||||||
|
/**
|
||||||
|
* If parent id was not in the mirror map any more, it
|
||||||
|
* means the parent node has already been removed. So
|
||||||
|
* the node is also removed which we do not need to track
|
||||||
|
* and replay.
|
||||||
|
*/
|
||||||
|
} else if (
|
||||||
|
this.movedSet.has(n) &&
|
||||||
|
this.movedMap[moveKey(nodeId, parentId)]
|
||||||
|
) {
|
||||||
|
deepDelete(this.movedSet, n);
|
||||||
|
} else {
|
||||||
|
this.removes.push({
|
||||||
|
parentId,
|
||||||
|
id: nodeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mirror.removeNodeFromMap(n as INode);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private genAdds = (n: Node | INode, target?: Node | INode) => {
|
||||||
|
if (isBlocked(n, this.blockClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isINode(n)) {
|
||||||
|
this.movedSet.add(n);
|
||||||
|
let targetId: number | null = null;
|
||||||
|
if (target && isINode(target)) {
|
||||||
|
targetId = target.__sn.id;
|
||||||
|
}
|
||||||
|
if (targetId) {
|
||||||
|
this.movedMap[moveKey(n.__sn.id, targetId)] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addedSet.add(n);
|
||||||
|
this.droppedSet.delete(n);
|
||||||
|
}
|
||||||
|
n.childNodes.forEach((childN) => this.genAdds(childN));
|
||||||
|
};
|
||||||
|
|
||||||
|
public emit = () => {
|
||||||
|
const payload = {
|
||||||
|
texts: this.texts
|
||||||
|
.map((text) => ({
|
||||||
|
id: mirror.getId(text.node as INode),
|
||||||
|
value: text.value,
|
||||||
|
}))
|
||||||
|
// text mutation's id was not in the mirror map means the target node has been removed
|
||||||
|
.filter((text) => mirror.has(text.id)),
|
||||||
|
attributes: this.attributes
|
||||||
|
.map((attribute) => ({
|
||||||
|
id: mirror.getId(attribute.node as INode),
|
||||||
|
attributes: attribute.attributes,
|
||||||
|
}))
|
||||||
|
// attribute mutation's id was not in the mirror map means the target node has been removed
|
||||||
|
.filter((attribute) => mirror.has(attribute.id)),
|
||||||
|
removes: this.removes,
|
||||||
|
adds: this.adds,
|
||||||
|
};
|
||||||
|
// payload may be empty if the mutations happened in some blocked elements
|
||||||
|
if (
|
||||||
|
!payload.texts.length &&
|
||||||
|
!payload.attributes.length &&
|
||||||
|
!payload.removes.length &&
|
||||||
|
!payload.adds.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emissionCallback(payload);
|
||||||
|
|
||||||
|
// reset
|
||||||
|
this.texts = [];
|
||||||
|
this.attributes = [];
|
||||||
|
this.removes = [];
|
||||||
|
this.adds = [];
|
||||||
|
this.addedSet = new Set<Node>();
|
||||||
|
this.movedSet = new Set<Node>();
|
||||||
|
this.droppedSet = new Set<Node>();
|
||||||
|
this.movedMap = {};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some utils to handle the mutation observer DOM records.
|
||||||
|
* It should be more clear to extend the native data structure
|
||||||
|
* like Set and Map, but currently Typescript does not support
|
||||||
|
* that.
|
||||||
|
*/
|
||||||
|
function deepDelete(addsSet: Set<Node>, n: Node) {
|
||||||
|
addsSet.delete(n);
|
||||||
|
n.childNodes.forEach((childN) => deepDelete(addsSet, childN));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParentRemoved(removes: removedNodeMutation[], n: Node): boolean {
|
||||||
|
const { parentNode } = n;
|
||||||
|
if (!parentNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parentId = mirror.getId((parentNode as Node) as INode);
|
||||||
|
if (removes.some((r) => r.id === parentId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isParentRemoved(removes, parentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
|
||||||
|
const { parentNode } = n;
|
||||||
|
if (!parentNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (set.has(parentNode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isAncestorInSet(set, parentNode);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { INode, serializeNodeWithId, transformAttribute } from 'rrweb-snapshot';
|
import { INode } from 'rrweb-snapshot';
|
||||||
import {
|
import {
|
||||||
mirror,
|
mirror,
|
||||||
throttle,
|
throttle,
|
||||||
@@ -7,13 +7,10 @@ import {
|
|||||||
getWindowHeight,
|
getWindowHeight,
|
||||||
getWindowWidth,
|
getWindowWidth,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isAncestorRemoved,
|
|
||||||
isTouchEvent,
|
isTouchEvent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import {
|
import {
|
||||||
mutationCallBack,
|
mutationCallBack,
|
||||||
removedNodeMutation,
|
|
||||||
addedNodeMutation,
|
|
||||||
observerParam,
|
observerParam,
|
||||||
mousemoveCallBack,
|
mousemoveCallBack,
|
||||||
mousePosition,
|
mousePosition,
|
||||||
@@ -26,8 +23,6 @@ import {
|
|||||||
inputValue,
|
inputValue,
|
||||||
inputCallback,
|
inputCallback,
|
||||||
hookResetter,
|
hookResetter,
|
||||||
textCursor,
|
|
||||||
attributeCursor,
|
|
||||||
blockClass,
|
blockClass,
|
||||||
IncrementalSource,
|
IncrementalSource,
|
||||||
hooksParam,
|
hooksParam,
|
||||||
@@ -35,241 +30,22 @@ import {
|
|||||||
mediaInteractionCallback,
|
mediaInteractionCallback,
|
||||||
MediaInteractions,
|
MediaInteractions,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { deepDelete, isParentRemoved, isAncestorInSet } from './collection';
|
import MutationBuffer from './mutation';
|
||||||
|
|
||||||
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
|
|
||||||
function isINode(n: Node | INode): n is INode {
|
|
||||||
return '__sn' in n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
function initMutationObserver(
|
||||||
cb: mutationCallBack,
|
cb: mutationCallBack,
|
||||||
blockClass: blockClass,
|
blockClass: blockClass,
|
||||||
inlineStylesheet: boolean,
|
inlineStylesheet: boolean,
|
||||||
maskAllInputs: boolean,
|
maskAllInputs: boolean,
|
||||||
): MutationObserver {
|
): MutationObserver {
|
||||||
const observer = new MutationObserver((mutations) => {
|
// see mutation.ts for details
|
||||||
const texts: textCursor[] = [];
|
const mutationBuffer = new MutationBuffer(
|
||||||
const attributes: attributeCursor[] = [];
|
cb,
|
||||||
let removes: removedNodeMutation[] = [];
|
blockClass,
|
||||||
const adds: addedNodeMutation[] = [];
|
inlineStylesheet,
|
||||||
|
maskAllInputs,
|
||||||
const addedSet = new Set<Node>();
|
);
|
||||||
const movedSet = new Set<Node>();
|
const observer = new MutationObserver(mutationBuffer.processMutations);
|
||||||
const droppedSet = new Set<Node>();
|
|
||||||
|
|
||||||
const movedMap: Record<string, true> = {};
|
|
||||||
|
|
||||||
const genAdds = (n: Node | INode, target?: Node | INode) => {
|
|
||||||
if (isBlocked(n, blockClass)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isINode(n)) {
|
|
||||||
movedSet.add(n);
|
|
||||||
let targetId: number | null = null;
|
|
||||||
if (target && isINode(target)) {
|
|
||||||
targetId = target.__sn.id;
|
|
||||||
}
|
|
||||||
if (targetId) {
|
|
||||||
movedMap[moveKey(n.__sn.id, targetId)] = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addedSet.add(n);
|
|
||||||
droppedSet.delete(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 (!isBlocked(target, blockClass) && value !== oldValue) {
|
|
||||||
texts.push({
|
|
||||||
value,
|
|
||||||
node: target,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'attributes': {
|
|
||||||
const value = (target as HTMLElement).getAttribute(attributeName!);
|
|
||||||
if (isBlocked(target, blockClass) || 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!] = transformAttribute(
|
|
||||||
document,
|
|
||||||
attributeName!,
|
|
||||||
value!,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'childList': {
|
|
||||||
addedNodes.forEach((n) => genAdds(n, target));
|
|
||||||
removedNodes.forEach((n) => {
|
|
||||||
const nodeId = mirror.getId(n as INode);
|
|
||||||
const parentId = mirror.getId(target as INode);
|
|
||||||
if (isBlocked(n, blockClass)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// removed node has not been serialized yet, just remove it from the Set
|
|
||||||
if (addedSet.has(n)) {
|
|
||||||
deepDelete(addedSet, n);
|
|
||||||
droppedSet.add(n);
|
|
||||||
} else if (addedSet.has(target) && nodeId === -1) {
|
|
||||||
/**
|
|
||||||
* 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 because
|
|
||||||
* newly added node will be serialized without child nodes.
|
|
||||||
* TODO: verify this
|
|
||||||
*/
|
|
||||||
} else if (isAncestorRemoved(target as INode)) {
|
|
||||||
/**
|
|
||||||
* If parent id was not in the mirror map any more, it
|
|
||||||
* means the parent node has already been removed. So
|
|
||||||
* the node is also removed which we do not need to track
|
|
||||||
* and replay.
|
|
||||||
*/
|
|
||||||
} else if (movedSet.has(n) && movedMap[moveKey(nodeId, parentId)]) {
|
|
||||||
deepDelete(movedSet, n);
|
|
||||||
} else {
|
|
||||||
removes.push({
|
|
||||||
parentId,
|
|
||||||
id: nodeId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
mirror.removeNodeFromMap(n as INode);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sometimes child node may be pushed before its newly added
|
|
||||||
* parent, so we init a queue to store these nodes.
|
|
||||||
*/
|
|
||||||
const addQueue: Node[] = [];
|
|
||||||
const pushAdd = (n: Node) => {
|
|
||||||
const parentId = mirror.getId((n.parentNode as Node) as INode);
|
|
||||||
const nextId =
|
|
||||||
n.nextSibling && mirror.getId((n.nextSibling as unknown) as INode);
|
|
||||||
if (parentId === -1 || nextId === -1) {
|
|
||||||
return addQueue.push(n);
|
|
||||||
}
|
|
||||||
adds.push({
|
|
||||||
parentId,
|
|
||||||
nextId,
|
|
||||||
node: serializeNodeWithId(
|
|
||||||
n,
|
|
||||||
document,
|
|
||||||
mirror.map,
|
|
||||||
blockClass,
|
|
||||||
true,
|
|
||||||
inlineStylesheet,
|
|
||||||
maskAllInputs,
|
|
||||||
)!,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const n of movedSet) {
|
|
||||||
pushAdd(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const n of addedSet) {
|
|
||||||
if (!isAncestorInSet(droppedSet, n) && !isParentRemoved(removes, n)) {
|
|
||||||
pushAdd(n);
|
|
||||||
} else if (isAncestorInSet(movedSet, n)) {
|
|
||||||
pushAdd(n);
|
|
||||||
} else {
|
|
||||||
droppedSet.add(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (addQueue.length) {
|
|
||||||
if (
|
|
||||||
addQueue.every(
|
|
||||||
(n) => mirror.getId((n.parentNode as Node) as INode) === -1,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* If all nodes in queue could not find a serialized parent,
|
|
||||||
* it may be a bug or corner case. We need to escape the
|
|
||||||
* dead while loop at once.
|
|
||||||
*/
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
pushAdd(addQueue.shift()!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
texts: texts
|
|
||||||
.map((text) => ({
|
|
||||||
id: mirror.getId(text.node as INode),
|
|
||||||
value: text.value,
|
|
||||||
}))
|
|
||||||
// text mutation's id was not in the mirror map means the target node has been removed
|
|
||||||
.filter((text) => mirror.has(text.id)),
|
|
||||||
attributes: attributes
|
|
||||||
.map((attribute) => ({
|
|
||||||
id: mirror.getId(attribute.node as INode),
|
|
||||||
attributes: attribute.attributes,
|
|
||||||
}))
|
|
||||||
// attribute mutation's id was not in the mirror map means the target node has been removed
|
|
||||||
.filter((attribute) => mirror.has(attribute.id)),
|
|
||||||
removes,
|
|
||||||
adds,
|
|
||||||
};
|
|
||||||
// payload may be empty if the mutations happened in some blocked elements
|
|
||||||
if (
|
|
||||||
!payload.texts.length &&
|
|
||||||
!payload.attributes.length &&
|
|
||||||
!payload.removes.length &&
|
|
||||||
!payload.adds.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cb(payload);
|
|
||||||
});
|
|
||||||
observer.observe(document, {
|
observer.observe(document, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeOldValue: true,
|
attributeOldValue: true,
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -166,6 +166,16 @@ export type hooksParam = {
|
|||||||
styleSheetRule?: styleSheetRuleCallback;
|
styleSheetRule?: styleSheetRuleCallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// https://dom.spec.whatwg.org/#interface-mutationrecord
|
||||||
|
export type mutationRecord = {
|
||||||
|
type: string,
|
||||||
|
target: Node,
|
||||||
|
oldValue: string | null,
|
||||||
|
addedNodes: NodeList,
|
||||||
|
removedNodes: NodeList,
|
||||||
|
attributeName: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
export type textCursor = {
|
export type textCursor = {
|
||||||
node: Node;
|
node: Node;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
|||||||
20
typings/record/mutation.d.ts
vendored
Normal file
20
typings/record/mutation.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { mutationRecord, blockClass, mutationCallBack } from '../types';
|
||||||
|
export default class MutationBuffer {
|
||||||
|
private texts;
|
||||||
|
private attributes;
|
||||||
|
private removes;
|
||||||
|
private adds;
|
||||||
|
private movedMap;
|
||||||
|
private addedSet;
|
||||||
|
private movedSet;
|
||||||
|
private droppedSet;
|
||||||
|
private emissionCallback;
|
||||||
|
private blockClass;
|
||||||
|
private inlineStylesheet;
|
||||||
|
private maskAllInputs;
|
||||||
|
constructor(cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, maskAllInputs: boolean);
|
||||||
|
processMutations(mutations: mutationRecord[]): void;
|
||||||
|
private processMutation;
|
||||||
|
private genAdds;
|
||||||
|
emit(): void;
|
||||||
|
}
|
||||||
8
typings/types.d.ts
vendored
8
typings/types.d.ts
vendored
@@ -127,6 +127,14 @@ export declare type hooksParam = {
|
|||||||
mediaInteaction?: mediaInteractionCallback;
|
mediaInteaction?: mediaInteractionCallback;
|
||||||
styleSheetRule?: styleSheetRuleCallback;
|
styleSheetRule?: styleSheetRuleCallback;
|
||||||
};
|
};
|
||||||
|
export declare type mutationRecord = {
|
||||||
|
type: string;
|
||||||
|
target: Node;
|
||||||
|
oldValue: string | null;
|
||||||
|
addedNodes: NodeList;
|
||||||
|
removedNodes: NodeList;
|
||||||
|
attributeName: string | null;
|
||||||
|
};
|
||||||
export declare type textCursor = {
|
export declare type textCursor = {
|
||||||
node: Node;
|
node: Node;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user