Skip to content
This repository has been archived by the owner on Sep 26, 2024. It is now read-only.

Commit

Permalink
MOC-150 handle DOM mutations (#165)
Browse files Browse the repository at this point in the history
* Infrastructure for tracking reactor changes in DOM

* Full mutation handling

* Fix bug unapplying after DOM modifications

* Fix MOC-223: handle undoing text changes at the end of an element

* Fix colors in diff
  • Loading branch information
jonathankap authored Aug 29, 2024
1 parent b002319 commit fc9ba5e
Show file tree
Hide file tree
Showing 10 changed files with 412 additions and 36 deletions.
62 changes: 61 additions & 1 deletion packages/reactor/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { generateRandomString } from "./utils";

export interface Modification {
selector?: string;
xpath?: string;
Expand Down Expand Up @@ -50,20 +52,78 @@ export interface Highlighter {

export abstract class AppliableModification {
doc: Document;
uuid: string;
highlightNodes: Node[] = [];
elementState: { [key: string]: any } = {};

constructor(doc: Document) {
this.doc = doc;
this.uuid = generateRandomString(8);
}

abstract apply(): void;
abstract unapply(): void;

addModifiedElement(element: Element): string {
let mocksiId = element.getAttribute("mocksi-id");
if (!mocksiId) {
mocksiId = generateRandomString(8);
element.setAttribute("mocksi-id", mocksiId);
}

element.setAttribute(`mocksi-modified-${this.uuid}`, "true");

return mocksiId;
}

removeModifiedElement(element: Element): void {
let mocksiId = element.getAttribute("mocksi-id");
if (mocksiId) {
this.removeElementState(mocksiId);
}

element.removeAttribute(`mocksi-modified-${this.uuid}`);
}

getModifiedElement(mocksiId: string): Element | null {
return this.doc.querySelector(`[mocksi-id="${mocksiId}"]`);
}

getModifiedElements(): Element[] {
return Array.from(
this.doc.querySelectorAll(`[mocksi-modified-${this.uuid}]`),
) as Element[];
}

// returns true if the modification is no longer needed because it no
// longer applied to any nodes. In that case it will be removed from
// the list of modifications
modifiedElementRemoved(element: Element, mocksiId: string): boolean {
this.removeElementState(mocksiId);
return this.elementState.length === 0;
}

getMocksiId(element: Element): string {
return element.getAttribute("mocksi-id") || "";
}

setElementState(mocksiId: string, state: any): void {
this.elementState[mocksiId] = state;
}

getElementState(mocksiId: string): any {
return this.elementState[mocksiId];
}

removeElementState(mocksiId: string): void {
delete this.elementState[mocksiId];
}

getHighlightNodes(): Node[] {
return this.highlightNodes;
}

addHighlightNode(node: Node): void {
this.highlightNodes.push(node);
}
}
}
11 changes: 11 additions & 0 deletions packages/reactor/modifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,14 @@ export async function applyModification(
modification.apply();
return modification;
}

export function matchesSelector(element: Element, mod: Modification): boolean {
if (mod.selector) {
return element.matches(mod.selector);
} else if (mod.xpath) {
const xpathResult = document.evaluate(mod.xpath, document, null, XPathResult.BOOLEAN_TYPE, null);
return xpathResult.booleanValue;
}

return false;
}
23 changes: 15 additions & 8 deletions packages/reactor/modifications/adjacentHTML.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { AppliableModification } from "../interfaces";

export class AdjacentHTMLModification extends AppliableModification {
element: Element;
elementId: string;
position: InsertPosition;
oldValue: string;
newValue: string;

constructor(
Expand All @@ -13,19 +12,27 @@ export class AdjacentHTMLModification extends AppliableModification {
newValue: string,
) {
super(doc);
this.element = element;

this.position = position;
this.newValue = newValue;
this.oldValue = element.outerHTML;

this.elementId = this.addModifiedElement(element);
}

apply(): void {
this.element.insertAdjacentHTML(this.position, this.newValue);

// TODO - highlighting
for (const element of this.getModifiedElements()) {
const oldValue = element.outerHTML;
this.setElementState(this.getMocksiId(element), oldValue);
element.insertAdjacentHTML(this.position, this.newValue);
}
}

unapply(): void {
this.element.outerHTML = this.oldValue;
for (const element of this.getModifiedElements()) {
const oldValue = this.getElementState(this.getMocksiId(element));
if (oldValue) {
element.outerHTML = oldValue;
}
}
}
}
67 changes: 59 additions & 8 deletions packages/reactor/modifications/replaceAll.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
import { AppliableModification } from "../interfaces";
import * as cssSelector from "css-selector-generator";

export class ReplaceAllModification extends AppliableModification {
element: Element;
content: string;
changes: TreeChange[] = [];
observer: MutationObserver;

constructor(doc: Document, element: Element, content: string) {
super(doc);
this.element = element;
this.content = content;

this.observer = new MutationObserver(this.handleMutation.bind(this));
}

apply(): void {
// mark the element as modified
this.addModifiedElement(this.element);

this.changes = walkTree(
this.element,
checkText(this.content),
replaceText(this.content, this.addHighlightNode.bind(this)),
replaceText(this.content,
this.addModifiedElement.bind(this),
this.addHighlightNode.bind(this)),
);

this.observer.observe(this.element, { childList: true, subtree: true });
}

unapply(): void {
this.observer.disconnect();

const reverseChanges = [...this.changes].reverse();
for (const change of reverseChanges) {
const parentElement = this.doc.querySelector(change.parentSelector);
const parentElement = this.getModifiedElement(change.parentMocksiId);
if (!parentElement) {
continue;
}
Expand All @@ -43,10 +54,45 @@ export class ReplaceAllModification extends AppliableModification {
parentElement.insertBefore(newTextNode, nextSibling);
}
}

handleMutation(mutations: MutationRecord[]) {
this.observer.disconnect();

for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const added of mutation.addedNodes) {
const changes = walkTree(
added,
checkText(this.content),
replaceText(this.content,
this.addModifiedElement.bind(this),
this.addHighlightNode.bind(this)),
);

console.debug(`Added: ${added.nodeName} changes: ${changes.length}`);

this.changes = this.changes.concat(changes);
}
}
}

this.observer.observe(this.element, { childList: true, subtree: true });
}

modifiedElementRemoved(element: Element, mocksiId: string): boolean {
const noState = super.modifiedElementRemoved(element, mocksiId);

// remove any changes that were made to this element
this.changes = this.changes.filter((c) => c.parentMocksiId !== mocksiId);

// if all changed nodes have been removed (including the element itself),
// it is safe to remove this modification
return noState && this.changes.length === 0;
}
}

type TreeChange = {
parentSelector: string;
parentMocksiId: string;
origText: string;
replaceStart: number;
replaceCount: number;
Expand Down Expand Up @@ -110,6 +156,7 @@ function checkText(pattern: string): (node: Node) => boolean {

function replaceText(
pattern: string,
addModifiedElement: (element: Element) => string,
addHighlightNode: (node: Node) => void,
): (node: Node) => TreeChange | null {
const { patternRegexp, replacement } = toRegExpPattern(pattern);
Expand All @@ -127,13 +174,17 @@ function replaceText(
if (!parentElement) {
return null;
}
const parentSelector = cssSelector.getCssSelector(parentElement);
const parentMocksiId = addModifiedElement(parentElement);

let replaceStart = 0;
const nextSibling = node.nextSibling;
if (nextSibling) {
const prevSibling = node.previousSibling;
if (prevSibling || nextSibling) {
for (let i = 0; i < parentElement.childNodes.length; i++) {
if (parentElement.childNodes[i] === nextSibling) {
if (parentElement.childNodes[i] === prevSibling) {
replaceStart = i + 1;
break;
} else if (parentElement.childNodes[i] === nextSibling) {
replaceStart = i - 1;
break;
}
Expand All @@ -154,7 +205,7 @@ function replaceText(
}

return {
parentSelector: parentSelector,
parentMocksiId: parentMocksiId,
origText: node.nodeValue || "",
replaceStart: replaceStart,
replaceCount: split.length,
Expand Down
107 changes: 106 additions & 1 deletion packages/reactor/mutationObserver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import Reactor from "./reactor";
import { AppliedModificationsImpl } from "./modifications";
import { applyModification, matchesSelector } from "./modifications";

export class ReactorMutationObserver {
private reactor: Reactor;
private observer: MutationObserver | undefined;

constructor(reactor: Reactor) {
this.reactor = reactor;
}

attach(root: Document) {
this.observer = new MutationObserver(this.handleMutations.bind(this));
this.observer.observe(root, { childList: true, subtree: true });
Expand All @@ -16,5 +25,101 @@ export class ReactorMutationObserver {
}
}

handleMutation(mutation: MutationRecord) {}
handleMutation(mutation: MutationRecord) {
console.debug(`Mutation: ${mutation.type} added: ${mutation.addedNodes.length} removed: ${mutation.removedNodes.length}`);
for (const node of mutation.addedNodes) {
console.debug(` Added: ${printNode(node)}`);
if (node instanceof Element) {
this.walkAddedElements(node);
}
}
for (const node of mutation.removedNodes) {
console.debug(` Removed: ${printNode(node)}`);
if (node instanceof Element) {
this.walkRemovedElements(node);
}
}
}

walkAddedElements(element: Element) {
const treeWalker = document.createTreeWalker(
element,
NodeFilter.SHOW_ELEMENT,
null
);

do {
const node = treeWalker.currentNode;
if (node instanceof Element) {
const applied = Array.from(this.reactor.getAppliedModifications()) as AppliedModificationsImpl[];
for (const modification of applied) {
const reqs = modification.modificationRequest;
for (const req of reqs.modifications) {
if (this.reactor.doc && matchesSelector(node, req)) {
// apply modificaiton, but don't wait for result in mutation observer
applyModification(node, req, this.reactor.doc).then((appliedModification) => {
modification.modifications.push(appliedModification);
});
}
}
}
}
} while (treeWalker.nextNode());
}

walkRemovedElements(element: Element) {
const treeWalker = document.createTreeWalker(
element,
NodeFilter.SHOW_ELEMENT,
null
);

do {
const node = treeWalker.currentNode;
if (node instanceof Element) {
const mocksiId = node.getAttribute("mocksi-id");
if (mocksiId) {
for (const attributes of node.attributes) {
if (attributes.name.startsWith("mocksi-modified-")) {
const modificationId = attributes.name.substring("mocksi-modified-".length);
console.debug(` Modified by: ${modificationId}`);
this.removeModifiedElement(node, mocksiId, modificationId);
}
}
}
}
} while (treeWalker.nextNode());
}

removeModifiedElement(element: Element, elementId: string, modificationId: string) {
const applied = Array.from(this.reactor.getAppliedModifications()) as AppliedModificationsImpl[];
for (const modification of applied) {
for (const [index, mod] of modification.modifications.entries()) {
if (mod.uuid === modificationId) {
const remove = mod.modifiedElementRemoved(element, elementId);
if (remove) {
modification.modifications.splice(index, 1);
}
return;
}
}
}
}
}

const printNode = (node: Node) => {
let out = node.nodeName;

if (node instanceof Text) {
out += `: ${node.nodeValue}`;
}

if (node instanceof Element) {
const mocksiId = node.getAttribute("mocksi-id");
if (mocksiId) {
out += ` (mocksi-id: ${mocksiId})`;
}
}

return out;
}
Loading

0 comments on commit fc9ba5e

Please sign in to comment.