diff --git a/packages/reactor/interfaces.ts b/packages/reactor/interfaces.ts index b20eedfb..ec0f40b6 100644 --- a/packages/reactor/interfaces.ts +++ b/packages/reactor/interfaces.ts @@ -1,3 +1,5 @@ +import { generateRandomString } from "./utils"; + export interface Modification { selector?: string; xpath?: string; @@ -50,15 +52,73 @@ 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; } @@ -66,4 +126,4 @@ export abstract class AppliableModification { addHighlightNode(node: Node): void { this.highlightNodes.push(node); } -} +} \ No newline at end of file diff --git a/packages/reactor/modifications.ts b/packages/reactor/modifications.ts index 2506b42e..dcaaf5db 100644 --- a/packages/reactor/modifications.ts +++ b/packages/reactor/modifications.ts @@ -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; +} diff --git a/packages/reactor/modifications/adjacentHTML.ts b/packages/reactor/modifications/adjacentHTML.ts index 6b0dce19..15f2d591 100644 --- a/packages/reactor/modifications/adjacentHTML.ts +++ b/packages/reactor/modifications/adjacentHTML.ts @@ -1,9 +1,8 @@ import { AppliableModification } from "../interfaces"; export class AdjacentHTMLModification extends AppliableModification { - element: Element; + elementId: string; position: InsertPosition; - oldValue: string; newValue: string; constructor( @@ -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; + } + } } } diff --git a/packages/reactor/modifications/replaceAll.ts b/packages/reactor/modifications/replaceAll.ts index f5a1a125..61c11524 100644 --- a/packages/reactor/modifications/replaceAll.ts +++ b/packages/reactor/modifications/replaceAll.ts @@ -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; } @@ -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; @@ -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); @@ -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; } @@ -154,7 +205,7 @@ function replaceText( } return { - parentSelector: parentSelector, + parentMocksiId: parentMocksiId, origText: node.nodeValue || "", replaceStart: replaceStart, replaceCount: split.length, diff --git a/packages/reactor/mutationObserver.ts b/packages/reactor/mutationObserver.ts index 826b0c12..e3373cf5 100644 --- a/packages/reactor/mutationObserver.ts +++ b/packages/reactor/mutationObserver.ts @@ -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 }); @@ -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; } diff --git a/packages/reactor/reactor.ts b/packages/reactor/reactor.ts index 39938157..b26661ff 100644 --- a/packages/reactor/reactor.ts +++ b/packages/reactor/reactor.ts @@ -20,13 +20,13 @@ class Reactor { private mutationObserver: ReactorMutationObserver; private attached = false; - private doc?: Document = undefined; + doc?: Document = undefined; private highlighter?: Highlighter = undefined; private modifications: ModificationRequest[] = []; private appliedModifications: AppliedModificationsImpl[] = []; constructor() { - this.mutationObserver = new ReactorMutationObserver(); + this.mutationObserver = new ReactorMutationObserver(this); } /** @@ -42,7 +42,6 @@ class Reactor { this.doc = root; this.highlighter = highlighter; - this.mutationObserver.attach(root); this.attached = true; // apply all modifications @@ -51,6 +50,9 @@ class Reactor { await generateModifications(modification, root, highlighter), ); } + + // attach mutation observer after all modifications are applied + this.mutationObserver.attach(root); } /** @@ -152,6 +154,9 @@ class Reactor { this.modifications.push(modification); if (this.isAttached() && this.doc) { + // disable the mutation listener while we make our changes + this.mutationObserver.detach(); + const applied = await generateModifications( modification, this.doc, @@ -159,6 +164,9 @@ class Reactor { ); out.push(applied); this.appliedModifications.push(applied); + + // re-enable the mutation listener + this.mutationObserver.attach(this.doc); } } @@ -176,13 +184,19 @@ class Reactor { for (let i = 0; i < count; i++) { const modification = this.modifications.pop(); - if (this.isAttached()) { + if (this.isAttached() && this.doc) { + // disable the mutation listener while we make our changes + this.mutationObserver.detach(); + const applied = this.appliedModifications.pop(); if (applied) { applied.setHighlight(false); applied.unapply(); out.push(applied); } + + // re-enable the mutation listener + this.mutationObserver.attach(this.doc); } } diff --git a/packages/reactor/tests/modifications.test.ts b/packages/reactor/tests/modifications.test.ts index 21afe2e5..20670177 100644 --- a/packages/reactor/tests/modifications.test.ts +++ b/packages/reactor/tests/modifications.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { extendExpect } from "./test.utils"; import type { Modification, ModificationRequest } from "../interfaces"; -// utils.test.ts import { applyModification, generateModifications } from "../modifications"; import { createToast } from "../modifications/toast"; +extendExpect(expect); + describe("Utils", () => { let doc: Document; @@ -51,7 +53,7 @@ describe("Utils", () => { element.innerHTML = "
Old Content
"; await applyModification(element, modification, doc); - expect(element.innerHTML).toBe("New Content
"); + expect(element.innerHTML).toMatchIgnoringMocksiTags("New Content
"); }); it("should unapply replace all correctly", async () => { @@ -66,7 +68,7 @@ describe("Utils", () => { const modifications = await applyModification(element, modification, doc); modifications.unapply(); - expect(element.innerHTML).toBe("Old Content
"); + expect(element.innerHTML).toMatchIgnoringMocksiTags("Old Content
"); }); it("should preserve capitals in replacement", async () => { @@ -80,7 +82,7 @@ describe("Utils", () => { element.innerHTML = "Old Content is old
"; await applyModification(element, modification, doc); - expect(element.innerHTML).toBe("New Content is new
"); + expect(element.innerHTML).toMatchIgnoringMocksiTags("New Content is new
"); }); it("should preserve plurals in replacement", async () => { @@ -94,7 +96,7 @@ describe("Utils", () => { element.innerHTML = "Trains are great! I love my train.
"; await applyModification(element, modification, doc); - expect(element.innerHTML).toBe( + expect(element.innerHTML).toMatchIgnoringMocksiTags( "Brains are great! I love my brain.
", ); }); @@ -111,7 +113,7 @@ describe("Utils", () => { "I was in training about trains, but it was a strain to train.
"; await applyModification(element, modification, doc); - expect(element.innerHTML).toBe( + expect(element.innerHTML).toMatchIgnoringMocksiTags( "I was in training about brains, but it was a strain to brain.
", ); }); @@ -128,7 +130,7 @@ describe("Utils", () => { 'Trains are great! A picture of a train
Trains are great! I love my train.
'; await applyModification(element, modification, doc); - expect(element.innerHTML).toBe( + expect(element.innerHTML).toMatchIgnoringMocksiTags( 'Brains are great! A picture of a brain
Brains are great! I love my brain.
', ); }); @@ -146,7 +148,7 @@ describe("Utils", () => { const modifications = await applyModification(element, modification, doc); modifications.unapply(); - expect(element.innerHTML).toBe( + expect(element.innerHTML).toMatchIgnoringMocksiTags( 'Trains are great! A picture of a train
Trains are great! I love my train.
', ); }); @@ -173,6 +175,37 @@ describe("Utils", () => { ); }); + it("should work with wikipedia", async() => { + const modification: Modification = { + action: "replaceAll", + content: "/Thailand/Russia/", + }; + + const element = doc.createElement("p"); + element.innerHTML = "The office of the \"President of the People's Committee\" (ประธานคณะกรรมการราษฎร), later changed to \"Prime Minister of Siam\" (นายกรัฐมนตรีสยาม), was first created in the Temporary Constitution of 1932. The office was modeled after the prime minister of the United Kingdom, as Siam became a parliamentary democracy in 1932 after a bloodless revolution. However, the idea of a separate head of government in Thailand is not new.
" + doc.body.appendChild(element); + + await applyModification(doc.body, modification, doc); + + expect(element.innerHTML).toMatchIgnoringMocksiTags("The office of the \"President of the People's Committee\" (ประธานคณะกรรมการราษฎร), later changed to \"Prime Minister of Siam\" (นายกรัฐมนตรีสยาม), was first created in the Temporary Constitution of 1932. The office was modeled after the prime minister of the United Kingdom, as Siam became a parliamentary democracy in 1932 after a bloodless revolution. However, the idea of a separate head of government in Russia is not new.
"); + }); + + it("should unapply from wikipedia", async() => { + const modification: Modification = { + action: "replaceAll", + content: "/Thailand/Russia/", + }; + + const element = doc.createElement("p"); + element.innerHTML = "The office of the \"President of the People's Committee\" (ประธานคณะกรรมการราษฎร), later changed to \"Prime Minister of Siam\" (นายกรัฐมนตรีสยาม), was first created in the Temporary Constitution of 1932. The office was modeled after the prime minister of the United Kingdom, as Siam became a parliamentary democracy in 1932 after a bloodless revolution. However, the idea of a separate head of government in Thailand is not new.
" + doc.body.appendChild(element); + + const modifications = await applyModification(doc.body, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toMatchIgnoringMocksiTags("The office of the \"President of the People's Committee\" (ประธานคณะกรรมการราษฎร), later changed to \"Prime Minister of Siam\" (นายกรัฐมนตรีสยาม), was first created in the Temporary Constitution of 1932. The office was modeled after the prime minister of the United Kingdom, as Siam became a parliamentary democracy in 1932 after a bloodless revolution. However, the idea of a separate head of government in Thailand is not new.
"); + }); + it("should append content correctly", async () => { const modification: Modification = { action: "append", @@ -185,7 +218,7 @@ describe("Utils", () => { element.appendChild(inner); await applyModification(inner, modification, doc); - expect(element.innerHTML).toBe( + expect(element.innerHTML).toMatchIgnoringMocksiTags( "Initial Content
New Content
Initial Content
Initial Content
New Content
Initial Content
Initial Content
Initial Content
Initial ContentComponent Content
", ); }); @@ -401,7 +434,7 @@ describe("Utils", () => { const modifications = await applyModification(inner, modification, doc); modifications.unapply(); - expect(element.innerHTML).toBe("Initial Content
"); + expect(element.innerHTML).toMatchIgnoringMocksiTags("Initial Content
"); }); it("should handle unknowns correctly", async () => { diff --git a/packages/reactor/tests/mutation.test.ts b/packages/reactor/tests/mutation.test.ts new file mode 100644 index 00000000..ca9cadd3 --- /dev/null +++ b/packages/reactor/tests/mutation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { extendExpect } from "./test.utils"; +import type { ModificationRequest } from "../interfaces"; +import Reactor from "../reactor"; + +extendExpect(expect); + +describe("test mutation listeners", {}, () => { + let doc: Document; + let reactor: Reactor; + + // Vitest beforeEach function for setup + beforeEach(() => { + doc = document.implementation.createHTMLDocument("Test Document"); + + reactor = new Reactor(); + reactor.attach(doc, { + highlightNode: (elementToHighlight: Node) => {}, + removeHighlightNode: (elementToUnhighlight: Node) => {} + }); + }); + + it("should handle an added mutation", async () => { + doc.body.innerHTML = "