├── src ├── index.ts ├── index.test.ts ├── domIterator.ts ├── config.ts ├── config.test.ts ├── domIterator.test.ts ├── util.ts ├── diff.ts ├── util.test.ts └── diff.test.ts ├── .gitignore ├── demo ├── image1.jpg ├── image2.jpg ├── main.js ├── main.css └── index.html ├── .prettierrc ├── docs ├── b0461121d182af6abb391a4773ce7643.jpg ├── d1db9052725e059892a5cd33b78c3fc4.jpg ├── index.html └── b63d919808b1fcf9f335.js ├── .editorconfig ├── tslint.json ├── CONTRIBUTING.md ├── jest.config.js ├── tsconfig.json ├── .vscode └── launch.json ├── .github └── workflows │ └── visual-dom-diff.yml ├── benchmark └── index.js ├── scripts └── jestSetUp.js ├── LICENSE ├── webpack.config.js ├── CHANGELOG.md ├── package.json └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './diff' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | /coverage/ 4 | /tsconfig.tsbuildinfo 5 | -------------------------------------------------------------------------------- /demo/image1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teamwork/visual-dom-diff/HEAD/demo/image1.jpg -------------------------------------------------------------------------------- /demo/image2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teamwork/visual-dom-diff/HEAD/demo/image2.jpg -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /docs/b0461121d182af6abb391a4773ce7643.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teamwork/visual-dom-diff/HEAD/docs/b0461121d182af6abb391a4773ce7643.jpg -------------------------------------------------------------------------------- /docs/d1db9052725e059892a5cd33b78c3fc4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teamwork/visual-dom-diff/HEAD/docs/d1db9052725e059892a5cd33b78c3fc4.jpg -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as diff from './diff' 2 | import * as index from './index' 3 | 4 | test('exports visualDomDiff', () => { 5 | expect(index.visualDomDiff).toBe(diff.visualDomDiff) 6 | }) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = LF 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "interface-name" : [true, "never-prefix"], 8 | "variable-name": { 9 | "options": ["allow-leading-underscore"] 10 | }, 11 | "no-parameter-reassignment": true, 12 | "max-classes-per-file": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Editor 4 | 5 | The recommended editor is [Visual Studio Code](https://code.visualstudio.com/). The config that works well with this project is [here](https://gist.github.com/gkubisa/331ba8b586720f3f0af353c666eb3b7d) and can be set up easily using [Settings Sync](https://marketplace.visualstudio.com/items?itemName=Shan.code-settings-sync). 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | setupFiles: ['/scripts/jestSetUp'], 4 | testMatch: [`/src/**/*.test.ts`], 5 | testEnvironment: 'node', 6 | collectCoverage: true, 7 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}'], 8 | coverageThreshold: { 9 | global: { 10 | branches: 100, 11 | functions: 100, 12 | lines: 100, 13 | statements: 100, 14 | }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es6", "dom"], 6 | "moduleResolution": "node", 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "preserveConstEnums": true, 10 | "declaration": true, 11 | "importHelpers": true, 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "esModuleInterop": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "vscode-jest-tests", 11 | "args": ["--runInBand", "--no-coverage", "${file}"], 12 | "cwd": "${workspaceFolder}", 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/visual-dom-diff.yml: -------------------------------------------------------------------------------- 1 | name: Pull request checks 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: 'npm' 19 | 20 | - name: Install npm dependencies 21 | run: npm ci 22 | 23 | - name: Run Prettier check 24 | run: npm run prettier 25 | 26 | - name: Run TSLint 27 | run: npm run tslint 28 | 29 | - name: Build and test 30 | run: npm run build -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | const benchmark = require('benchmark') 2 | const fs = require('fs') 3 | const jsdom = require('jsdom') 4 | const path = require('path') 5 | const diff = require('../lib/diff') 6 | 7 | const oldPath = path.join(__dirname, 'old.html') 8 | const newPath = path.join(__dirname, 'new.html') 9 | const oldHtml = fs.readFileSync(oldPath, 'utf8') 10 | const newHtml = fs.readFileSync(newPath, 'utf8') 11 | const oldNode = new jsdom.JSDOM(oldHtml).window.document.body 12 | const newNode = new jsdom.JSDOM(newHtml).window.document.body 13 | 14 | const suite = new benchmark.Suite() 15 | suite.add('diff', () => { 16 | diff.visualDomDiff(oldNode, newNode) 17 | }) 18 | suite.on('cycle', function(event) { 19 | console.log(String(event.target)) 20 | }) 21 | suite.run({ async: true }) 22 | -------------------------------------------------------------------------------- /scripts/jestSetUp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fixes `instanceof Error` in jest. The fix does not affect anything else - 3 | * not even `instanceof SomeSubClassOfError`. 4 | * 5 | * The problem is that jest runs tests in a separate vm (context). 6 | * Each vm has its own globals, for example Error. In some cases, 7 | * an object from a different vm enters the test vm. It has different 8 | * objects in the prototype chain, so `instanceof` does not work as expected. 9 | * See https://github.com/facebook/jest/issues/2549#issuecomment-423202304. 10 | */ 11 | Object.defineProperty(Error, Symbol.hasInstance, { 12 | value: function(value) { 13 | if (this === Error) { 14 | return Object.prototype.toString.call(value) === '[object Error]' 15 | } else { 16 | return Object.getPrototypeOf(Error)[Symbol.hasInstance].call( 17 | this, 18 | value, 19 | ) 20 | } 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Teamwork.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | const isDevServer = process.argv[1].indexOf('webpack-dev-server') >= 0 5 | const config = { 6 | mode: 'development', 7 | entry: __dirname + '/demo/main.js', 8 | output: { 9 | filename: '[hash].js', 10 | path: __dirname + '/docs/', 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.css$/, 16 | loader: 'style-loader!css-loader', 17 | }, 18 | { 19 | test: /\.(png|svg|jpg|gif)$/, 20 | loader: 'file-loader', 21 | }, 22 | { 23 | test: /\.html$/, 24 | loader: 'html-loader', 25 | }, 26 | ], 27 | }, 28 | plugins: [ 29 | new HtmlWebpackPlugin({ 30 | template: __dirname + '/demo/index.html', 31 | }), 32 | ], 33 | devServer: { 34 | open: true, 35 | port: 8028, 36 | }, 37 | } 38 | 39 | if (!isDevServer) { 40 | config.plugins.push(new CleanWebpackPlugin()) 41 | } 42 | 43 | module.exports = config 44 | -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | import { visualDomDiff } from '../lib' 2 | import './main.css' 3 | 4 | window.addEventListener('load', function() { 5 | const input1Html = document.getElementById('input1-html') 6 | const input1 = document.getElementById('input1') 7 | const input2Html = document.getElementById('input2-html') 8 | const input2 = document.getElementById('input2') 9 | const outputHtml = document.getElementById('output-html') 10 | const output = document.getElementById('output') 11 | const updateInput1 = function() { 12 | input1.firstChild.innerHTML = input1Html.value 13 | } 14 | const updateInput2 = function() { 15 | input2.firstChild.innerHTML = input2Html.value 16 | } 17 | const updateDiff = function() { 18 | output.innerHTML = '' 19 | output.appendChild(visualDomDiff(input1.firstChild, input2.firstChild)) 20 | outputHtml.value = output.innerHTML 21 | } 22 | 23 | input1Html.addEventListener('input', function() { 24 | updateInput1() 25 | updateDiff() 26 | }) 27 | input2Html.addEventListener('input', function() { 28 | updateInput2() 29 | updateDiff() 30 | }) 31 | updateInput1() 32 | updateInput2() 33 | updateDiff() 34 | }) 35 | -------------------------------------------------------------------------------- /demo/main.css: -------------------------------------------------------------------------------- 1 | textarea { 2 | display: block; 3 | padding: 0; 4 | resize: vertical; 5 | min-height: 10rem; 6 | } 7 | 8 | img { 9 | max-width: 100%; 10 | } 11 | 12 | table { 13 | border-collapse: collapse; 14 | } 15 | 16 | th, 17 | td { 18 | border: calc(1rem / 16) solid black; 19 | padding: 0.25rem; 20 | } 21 | 22 | .row { 23 | display: flex; 24 | flex: 1 0 0; 25 | flex-direction: row; 26 | box-sizing: border-box; 27 | min-height: 0.1rem; 28 | } 29 | 30 | .column { 31 | display: flex; 32 | flex: 1 0 0; 33 | flex-direction: column; 34 | box-sizing: border-box; 35 | min-width: 0.1rem; 36 | } 37 | 38 | .margin { 39 | margin: 0.25rem; 40 | } 41 | 42 | .right { 43 | float: right; 44 | } 45 | 46 | .vdd-added, 47 | .vdd-modified, 48 | .vdd-removed { 49 | text-decoration: none; 50 | } 51 | 52 | .vdd-added > *, 53 | .vdd-modified > *, 54 | .vdd-removed > * { 55 | text-decoration: none; 56 | background-color: inherit; 57 | } 58 | 59 | .vdd-added { 60 | background: lightgreen; 61 | } 62 | .vdd-added > img { 63 | border: 0.125em solid lightgreen; 64 | } 65 | 66 | .vdd-modified { 67 | background: lightskyblue; 68 | } 69 | .vdd-modified > img { 70 | border: 0.125em solid lightskyblue; 71 | } 72 | 73 | .vdd-removed { 74 | background: lightcoral; 75 | } 76 | .vdd-removed > img { 77 | border: 0.125em solid lightcoral; 78 | } 79 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | visual-dom-diff demo GitHub

visual-dom-diff

Original Content

Changed Content

Diff

-------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | visual-dom-diff demo 7 | 8 | 9 | 10 | GitHub 13 |

visual-dom-diff

14 |
15 |
16 |

Original Content

17 | 25 |
26 |
27 |
28 |

Changed Content

29 | 36 |
37 |
38 |
39 |

Diff

40 | 41 |
42 |
43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.7.2 2 | 3 | - Support IE11 without polyfills and transpiling. 4 | 5 | # 0.7.1 6 | 7 | - Fix a TypeError in table diff, which occured when THEAD or TFOOT was added or removed, and a column was removed. 8 | 9 | # 0.7.0 10 | 11 | - Add `diffText` option. 12 | - Fix table diffs. If a table can't be diffed correctly, both the old and new tables are added to the diff result with "vdd-removed" and "vdd-added" classes respectively. 13 | 14 | # 0.6.0 15 | 16 | - BREAKING CHANGE: Mark up the added and removed elements by adding the `vdd-added` and `vdd-removed` classes to the affected elements, instead of using the `` and `` wrappers. Text and formatting changes still use the `` and `` wrappers. 17 | - BREAKING CHANGE: Mark up structural elements (P, TABLE, DIV, etc) with modified attributes by adding the `vdd-modified` class to the affected elements, instead of outputting 2 elements with `vdd-added` and `vdd-removed` classes. Attribute changes on content elements (eg. IMG, IFRAME, SVG, etc) still output 2 elements with the `vdd-added` and `vdd-removed` classes. Formatting elements (eg STRONG, EM, etc) are unafected by this change. 18 | - Fix invalid characters sometimes appearing in diff results. 19 | - Improve table diffs by preferring cell content changes to cell removals and additions. 20 | 21 | # 0.5.2 22 | 23 | - Avoid adding change markers at invalid locations. 24 | 25 | # 0.5.1 26 | 27 | - Fix identical document structure sometimes marked up as changed. 28 | 29 | # 0.5.0 30 | 31 | - Expose `VisualDomDiffOption` type for TypeScript projects. 32 | 33 | # 0.4.0 34 | 35 | - Add `skipModified` option. 36 | 37 | # 0.3.0 38 | 39 | - BREAKING CHANGE: The `compareNodes` option is no longer supported. 40 | - Improve the diff output quality. 41 | 42 | # 0.2.0 43 | 44 | - BREAKING CHANGE: The `ignoreCase` option is no longer supported. 45 | - Use the `diff-match-patch` instead of the `diff` module to improve performance. 46 | 47 | # 0.1.3 48 | 49 | - Stop using Browser globals to support running in node with jsdom. 50 | 51 | # 0.1.2 52 | 53 | - Support diffing documents. 54 | 55 | # 0.1.1 56 | 57 | - Add the `compareNodes` option. 58 | 59 | # 0.1.0 60 | 61 | - Initial release. 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visual-dom-diff", 3 | "version": "0.7.3", 4 | "description": "Highlight differences between two DOM trees.", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "/lib", 9 | "!/lib/**/*.test.js", 10 | "!/lib/**/*.test.d.ts" 11 | ], 12 | "scripts": { 13 | "clean": "rimraf lib", 14 | "prettier-fix": "prettier \"./src/**/*\" \"./demo/**/*\" \"!./**/*.jpg\" --list-different --write", 15 | "prettier": "prettier \"./src/**/*\" \"./demo/**/*\" \"!./**/*.jpg\" --list-different", 16 | "tslint": "tslint --project .", 17 | "tsc": "tsc -b .", 18 | "test": "jest", 19 | "build": "run-s clean prettier tslint tsc test", 20 | "demo": "webpack -p", 21 | "start": "run-p start:*", 22 | "start:demo": "webpack-dev-server -d", 23 | "start:tsc": "tsc -b -w .", 24 | "preversion": "npm outdated && run-s build demo && git add docs", 25 | "postversion": "git push && git push origin v${npm_package_version}", 26 | "benchmark": "node benchmark" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/Teamwork/visual-dom-diff.git" 31 | }, 32 | "keywords": [ 33 | "visual", 34 | "dom", 35 | "diff" 36 | ], 37 | "author": "Greg Kubisa ", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/Teamwork/visual-dom-diff/issues" 41 | }, 42 | "homepage": "https://github.com/Teamwork/visual-dom-diff#readme", 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "npm run build" 46 | } 47 | }, 48 | "devDependencies": { 49 | "@types/jest": "^24.0.22", 50 | "@types/jsdom": "^12.2.4", 51 | "benchmark": "^2.1.4", 52 | "clean-webpack-plugin": "^3.0.0", 53 | "css-loader": "^3.2.0", 54 | "file-loader": "^4.2.0", 55 | "html-loader": "^0.5.5", 56 | "html-webpack-plugin": "^3.2.0", 57 | "husky": "^3.0.9", 58 | "jest": "^24.9.0", 59 | "jsdom": "^15.2.1", 60 | "npm-run-all": "^4.1.5", 61 | "prettier": "^1.18.2", 62 | "rimraf": "^3.0.0", 63 | "style-loader": "^1.0.0", 64 | "ts-jest": "^24.1.0", 65 | "tslint": "^5.20.1", 66 | "tslint-config-prettier": "^1.18.0", 67 | "typescript": "^3.7.2", 68 | "webpack": "^4.41.2", 69 | "webpack-cli": "^3.3.10", 70 | "webpack-dev-server": "^3.9.0" 71 | }, 72 | "dependencies": { 73 | "@types/diff-match-patch": "^1.0.32", 74 | "diff-match-patch": "^1.0.4" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # visual-dom-diff 2 | 3 | Highlight differences between two DOM trees. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm i visual-dom-diff 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```javascript 14 | import { visualDomDiff } from 'visual-dom-diff' 15 | 16 | const diffNode = visualDomDiff(originalNode, changedNode, options) 17 | ``` 18 | 19 | ## API 20 | 21 | ### visualDomDiff(originalNode: Node, changedNode: Node, options?: Options): DocumentFragment 22 | 23 | Returns a new document fragment with the content from the two input nodes and annotations indicating if the given fragment was removed, modified or added in the `changedNode`, ralative to the `originalNode`. 24 | 25 | Changes to text content are represented as deletions (``) followed by insertions (``). 26 | 27 | Changes to the document structure are indicated by adding the `vdd-removed` and `vdd-added` classes to the removed and inserted elements respectively. 28 | 29 | Changes to formatting are treated as content modifications (`` wraps the modified text) and only the new formatting is carried over to the returned document fragment. 30 | 31 | Changes to attributes of structural elements are treated as modifications (`vdd-modified` class is added to the element) and only the new attributes are carried over to the returned document fragment. 32 | 33 | #### Options 34 | 35 | - `addedClass: string = 'vdd-added'` The class used for annotating content additions. 36 | - `modifiedClass: string = 'vdd-modified'` The class used for annotating content modifications. 37 | - `removedClass: string = 'vdd-removed'` The class used for annotating content removals. 38 | - `skipModified: boolean = false` If `true`, then formatting changes are NOT wrapped in `` and modified structural elements are NOT annotated with the `vdd-modified` class. 39 | - `skipChildren: (node: Node): boolean | undefined` Indicates if the child nodes of the specified `node` should be ignored. It is useful for ignoring child nodes of an element representing some embedded content, which should not be compared. Return `undefined` for the default behaviour. 40 | - `skipSelf: (node: Node): boolean | undefined` Indicates if the specified `node` should be ignored. Even if the `node` is ignored, its child nodes will still be processed, unless `skipChildNodes` says they should also be ignored. Ignored elements whose child nodes are processed are treated as formatting elements. Return `undefined` for the default behaviour. 41 | - `diffText: (oldText: string, newText: string): Diff[]` A function to use for diffing serialized representations of DOM nodes, where each DOM element is represented by a single character from the Private Use Area of the Basic Multilingual Unicode Plane. The default implementation is case sensitive and inteligently merges related changes to make the result more user friendly. See the source code for more details, especially if you want to implement a custom `diffText` function. 42 | -------------------------------------------------------------------------------- /src/domIterator.ts: -------------------------------------------------------------------------------- 1 | import { NodePredicate } from './util' 2 | 3 | export interface DomIteratorOptions { 4 | skipSelf?: NodePredicate 5 | skipChildren?: NodePredicate 6 | } 7 | 8 | export class DomIterator implements Iterator { 9 | private nextNode: Node | null 10 | private descend: boolean = true 11 | 12 | public constructor( 13 | private rootNode: Node, 14 | private config?: DomIteratorOptions, 15 | ) { 16 | this.nextNode = this.rootNode 17 | if (this.skipSelf(this.nextNode)) { 18 | this.next() 19 | } 20 | } 21 | 22 | public toArray(): Node[] { 23 | const array: Node[] = [] 24 | let { done, value } = this.next() 25 | 26 | while (!done) { 27 | array.push(value) 28 | ;({ done, value } = this.next()) 29 | } 30 | 31 | return array 32 | } 33 | 34 | public forEach(fn: (node: Node) => void): void { 35 | let { done, value } = this.next() 36 | 37 | while (!done) { 38 | fn(value) 39 | ;({ done, value } = this.next()) 40 | } 41 | } 42 | 43 | public reduce(fn: (result: T, current: Node) => T, initial: T): T { 44 | let result = initial 45 | let { done, value } = this.next() 46 | 47 | while (!done) { 48 | result = fn(result, value) 49 | ;({ done, value } = this.next()) 50 | } 51 | 52 | return result 53 | } 54 | 55 | public some(fn: (node: Node) => boolean): boolean { 56 | let { done, value } = this.next() 57 | 58 | while (!done) { 59 | if (fn(value)) { 60 | return true 61 | } 62 | ;({ done, value } = this.next()) 63 | } 64 | 65 | return false 66 | } 67 | 68 | public next(): IteratorResult { 69 | if (!this.nextNode) { 70 | return { done: true, value: this.rootNode } 71 | } 72 | 73 | const value = this.nextNode 74 | const done = false 75 | 76 | if ( 77 | this.descend && 78 | this.nextNode.firstChild && 79 | !this.skipChildren(this.nextNode) 80 | ) { 81 | this.nextNode = this.nextNode.firstChild 82 | } else if (this.nextNode === this.rootNode) { 83 | this.nextNode = null 84 | } else if (this.nextNode.nextSibling) { 85 | this.nextNode = this.nextNode.nextSibling 86 | this.descend = true 87 | } else { 88 | this.nextNode = this.nextNode.parentNode 89 | this.descend = false 90 | this.next() // Skip this node, as we've visited it already. 91 | } 92 | 93 | if (this.nextNode && this.skipSelf(this.nextNode)) { 94 | this.next() // Skip this node, as directed by the config. 95 | } 96 | 97 | return { done, value } 98 | } 99 | 100 | private skipSelf(node: Node): boolean { 101 | return this.config && this.config.skipSelf 102 | ? this.config.skipSelf(node) 103 | : false 104 | } 105 | 106 | private skipChildren(node: Node): boolean { 107 | return this.config && this.config.skipChildren 108 | ? this.config.skipChildren(node) 109 | : false 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { DomIteratorOptions } from './domIterator' 2 | import { 3 | diffText as diffTextDefault, 4 | DiffTextType, 5 | IndefiniteNodePredicate, 6 | isDocument, 7 | isDocumentFragment, 8 | isElement, 9 | isText, 10 | NodePredicate, 11 | } from './util' 12 | 13 | /** 14 | * The options for `visualDomDiff`. 15 | */ 16 | export interface Options { 17 | /** 18 | * The class name to use to mark up inserted content. 19 | * Default is `'vdd-added'`. 20 | */ 21 | addedClass?: string 22 | /** 23 | * The class name to use to mark up modified content. 24 | * Default is `'vdd-modified'`. 25 | */ 26 | modifiedClass?: string 27 | /** 28 | * The class name to use to mark up removed content. 29 | * Default is `'vdd-removed'`. 30 | */ 31 | removedClass?: string 32 | /** 33 | * If `true`, the modified content (text formatting changes) will not be marked. 34 | * Default is `false`. 35 | */ 36 | skipModified?: boolean 37 | /** 38 | * Indicates if the child nodes of the specified `node` should be ignored. 39 | * It is useful for ignoring child nodes of an element representing some embedded content, 40 | * which should not be compared. Return `undefined` for the default behaviour. 41 | */ 42 | skipChildren?: IndefiniteNodePredicate 43 | /** 44 | * Indicates if the specified `node` should be ignored. 45 | * Even if the `node` is ignored, its child nodes will still be processed, 46 | * unless `skipChildNodes` says they should also be ignored. 47 | * Ignored elements whose child nodes are processed are treated as formatting elements. 48 | * Return `undefined` for the default behaviour. 49 | */ 50 | skipSelf?: IndefiniteNodePredicate 51 | /** 52 | * A plain-text diff function, which is used internally to compare serialized 53 | * representations of DOM nodes, where each DOM element is represented by a single 54 | * character from the Private Use Area of the Basic Multilingual Unicode Plane. It defaults 55 | * to [diff_main](https://github.com/google/diff-match-patch/wiki/API#diff_maintext1-text2--diffs). 56 | */ 57 | diffText?: DiffTextType 58 | } 59 | 60 | export interface Config extends Options, DomIteratorOptions { 61 | readonly addedClass: string 62 | readonly modifiedClass: string 63 | readonly removedClass: string 64 | readonly skipModified: boolean 65 | readonly skipChildren: NodePredicate 66 | readonly skipSelf: NodePredicate 67 | readonly diffText: DiffTextType 68 | } 69 | 70 | const skipChildrenMap = new Set() 71 | skipChildrenMap.add('IMG') 72 | skipChildrenMap.add('VIDEO') 73 | skipChildrenMap.add('IFRAME') 74 | skipChildrenMap.add('OBJECT') 75 | skipChildrenMap.add('SVG') 76 | 77 | const skipSelfMap = new Set() 78 | skipSelfMap.add('BDO') 79 | skipSelfMap.add('BDI') 80 | skipSelfMap.add('Q') 81 | skipSelfMap.add('CITE') 82 | skipSelfMap.add('CODE') 83 | skipSelfMap.add('DATA') 84 | skipSelfMap.add('TIME') 85 | skipSelfMap.add('VAR') 86 | skipSelfMap.add('DFN') 87 | skipSelfMap.add('ABBR') 88 | skipSelfMap.add('STRONG') 89 | skipSelfMap.add('EM') 90 | skipSelfMap.add('BIG') 91 | skipSelfMap.add('SMALL') 92 | skipSelfMap.add('MARK') 93 | skipSelfMap.add('SUB') 94 | skipSelfMap.add('SUP') 95 | skipSelfMap.add('SAMP') 96 | skipSelfMap.add('KBD') 97 | skipSelfMap.add('B') 98 | skipSelfMap.add('I') 99 | skipSelfMap.add('S') 100 | skipSelfMap.add('U') 101 | skipSelfMap.add('SPAN') 102 | 103 | export function optionsToConfig({ 104 | addedClass = 'vdd-added', 105 | modifiedClass = 'vdd-modified', 106 | removedClass = 'vdd-removed', 107 | skipModified = false, 108 | skipChildren, 109 | skipSelf, 110 | diffText = diffTextDefault, 111 | }: Options = {}): Config { 112 | return { 113 | addedClass, 114 | diffText, 115 | modifiedClass, 116 | removedClass, 117 | skipModified, 118 | skipChildren(node: Node): boolean { 119 | if ( 120 | !isElement(node) && 121 | !isDocumentFragment(node) && 122 | !isDocument(node) 123 | ) { 124 | return true 125 | } 126 | 127 | if (skipChildren) { 128 | const result = skipChildren(node) 129 | if (typeof result === 'boolean') { 130 | return result 131 | } 132 | } 133 | 134 | return skipChildrenMap.has(node.nodeName) 135 | }, 136 | skipSelf(node: Node): boolean { 137 | if (!isText(node) && !isElement(node)) { 138 | return true 139 | } 140 | 141 | if (skipSelf) { 142 | const result = skipSelf(node) 143 | if (typeof result === 'boolean') { 144 | return result 145 | } 146 | } 147 | return skipSelfMap.has(node.nodeName) 148 | }, 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/config.test.ts: -------------------------------------------------------------------------------- 1 | import { Diff } from 'diff-match-patch' 2 | import { JSDOM } from 'jsdom' 3 | import { optionsToConfig } from './config' 4 | import { diffText, isComment, isDocumentFragment, isText } from './util' 5 | 6 | const document = new JSDOM('').window.document 7 | const text = document.createTextNode('text') 8 | const span = document.createElement('SPAN') 9 | const div = document.createElement('DIV') 10 | const video = document.createElement('VIDEO') 11 | const comment = document.createComment('comment') 12 | const fragment = document.createDocumentFragment() 13 | 14 | describe('skipChildren', () => { 15 | describe('without options', () => { 16 | const config = optionsToConfig() 17 | test('return true given a text node', () => { 18 | expect(config.skipChildren(text)).toBe(true) 19 | }) 20 | test('return true given a comment node', () => { 21 | expect(config.skipChildren(comment)).toBe(true) 22 | }) 23 | test('return false given a SPAN', () => { 24 | expect(config.skipChildren(span)).toBe(false) 25 | }) 26 | test('return true given a VIDEO', () => { 27 | expect(config.skipChildren(video)).toBe(true) 28 | }) 29 | test('return false given a DIV', () => { 30 | expect(config.skipChildren(div)).toBe(false) 31 | }) 32 | test('return false given a document fragment', () => { 33 | expect(config.skipChildren(fragment)).toBe(false) 34 | }) 35 | test('return false given a document', () => { 36 | expect(config.skipChildren(document)).toBe(false) 37 | }) 38 | }) 39 | describe('with options', () => { 40 | const config = optionsToConfig({ 41 | skipChildren(node: Node): boolean | undefined { 42 | return node.nodeName === 'SPAN' 43 | ? true 44 | : node.nodeName === 'VIDEO' 45 | ? false 46 | : isText(node) || isComment(node) 47 | ? false 48 | : isDocumentFragment(node) 49 | ? true 50 | : undefined 51 | }, 52 | }) 53 | test('return true given a text node', () => { 54 | expect(config.skipChildren(text)).toBe(true) 55 | }) 56 | test('return true given a comment node', () => { 57 | expect(config.skipChildren(comment)).toBe(true) 58 | }) 59 | test('return true given a SPAN', () => { 60 | expect(config.skipChildren(span)).toBe(true) 61 | }) 62 | test('return false given a VIDEO', () => { 63 | expect(config.skipChildren(video)).toBe(false) 64 | }) 65 | test('return false given a DIV', () => { 66 | expect(config.skipChildren(div)).toBe(false) 67 | }) 68 | test('return true given a document fragment', () => { 69 | expect(config.skipChildren(fragment)).toBe(true) 70 | }) 71 | test('return false given a document', () => { 72 | expect(config.skipChildren(document)).toBe(false) 73 | }) 74 | }) 75 | }) 76 | 77 | describe('skipSelf', () => { 78 | describe('without options', () => { 79 | const config = optionsToConfig() 80 | test('return false given a text node', () => { 81 | expect(config.skipSelf(text)).toBe(false) 82 | }) 83 | test('return true given a comment node', () => { 84 | expect(config.skipSelf(comment)).toBe(true) 85 | }) 86 | test('return true given a SPAN', () => { 87 | expect(config.skipSelf(span)).toBe(true) 88 | }) 89 | test('return false given a VIDEO', () => { 90 | expect(config.skipSelf(video)).toBe(false) 91 | }) 92 | test('return false given a DIV', () => { 93 | expect(config.skipSelf(div)).toBe(false) 94 | }) 95 | test('return true given a document fragment', () => { 96 | expect(config.skipSelf(fragment)).toBe(true) 97 | }) 98 | }) 99 | describe('with options', () => { 100 | const config = optionsToConfig({ 101 | skipSelf(node: Node): boolean | undefined { 102 | return isText(node) 103 | ? true 104 | : isComment(node) 105 | ? false 106 | : isDocumentFragment(node) 107 | ? false 108 | : node.nodeName === 'SPAN' 109 | ? false 110 | : node.nodeName === 'VIDEO' 111 | ? true 112 | : undefined 113 | }, 114 | }) 115 | test('return true given a text node', () => { 116 | expect(config.skipSelf(text)).toBe(true) 117 | }) 118 | test('return true given a comment node', () => { 119 | expect(config.skipSelf(comment)).toBe(true) 120 | }) 121 | test('return false given a SPAN', () => { 122 | expect(config.skipSelf(span)).toBe(false) 123 | }) 124 | test('return true given a VIDEO', () => { 125 | expect(config.skipSelf(video)).toBe(true) 126 | }) 127 | test('return false given a DIV', () => { 128 | expect(config.skipSelf(div)).toBe(false) 129 | }) 130 | test('return true given a document fragment', () => { 131 | expect(config.skipSelf(fragment)).toBe(true) 132 | }) 133 | }) 134 | }) 135 | 136 | describe('simple options', () => { 137 | test('default', () => { 138 | const config = optionsToConfig() 139 | expect(config.addedClass).toBe('vdd-added') 140 | expect(config.diffText).toBe(diffText) 141 | expect(config.modifiedClass).toBe('vdd-modified') 142 | expect(config.removedClass).toBe('vdd-removed') 143 | expect(config.skipModified).toBe(false) 144 | }) 145 | test('override', () => { 146 | const customDiffText = ( 147 | _oldText: string, 148 | _newText: string, 149 | ): Diff[] => [] 150 | const config = optionsToConfig({ 151 | addedClass: 'ADDED', 152 | diffText: customDiffText, 153 | modifiedClass: 'MODIFIED', 154 | removedClass: 'REMOVED', 155 | skipModified: true, 156 | }) 157 | expect(config.addedClass).toBe('ADDED') 158 | expect(config.diffText).toBe(customDiffText) 159 | expect(config.modifiedClass).toBe('MODIFIED') 160 | expect(config.removedClass).toBe('REMOVED') 161 | expect(config.skipModified).toBe(true) 162 | }) 163 | }) 164 | -------------------------------------------------------------------------------- /src/domIterator.test.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom' 2 | import { DomIterator } from './domIterator' 3 | 4 | const document = new JSDOM('').window.document 5 | const text = document.createTextNode('text') 6 | const fragment = document.createDocumentFragment() 7 | const table = document.createElement('TABLE') 8 | const tr1 = document.createElement('TR') 9 | const tr2 = document.createElement('TR') 10 | const td1a = document.createElement('TD') 11 | const td1b = document.createElement('TD') 12 | const td2a = document.createElement('TD') 13 | const td2b = document.createElement('TD') 14 | const text1a = document.createTextNode('text1a') 15 | const text1b = document.createTextNode('text1b') 16 | const text2a = document.createTextNode('text2a') 17 | const text2b = document.createTextNode('text2b') 18 | const img1 = document.createElement('IMG') 19 | const img2 = document.createElement('IMG') 20 | const img3 = document.createElement('IMG') 21 | fragment.append(table, img1, img2, img3) 22 | table.append(tr1, tr2) 23 | tr1.append(td1a, td1b) 24 | tr2.append(td2a, td2b) 25 | td1a.append(text1a) 26 | td1b.append(text1b) 27 | td2a.append(text2a) 28 | td2b.append(text2b) 29 | 30 | test('iterate one text node', () => { 31 | expect(new DomIterator(text).toArray()).toStrictEqual([text]) 32 | }) 33 | 34 | test('iterate many nodes', () => { 35 | expect(new DomIterator(fragment).toArray()).toStrictEqual([ 36 | fragment, 37 | table, 38 | tr1, 39 | td1a, 40 | text1a, 41 | td1b, 42 | text1b, 43 | tr2, 44 | td2a, 45 | text2a, 46 | td2b, 47 | text2b, 48 | img1, 49 | img2, 50 | img3, 51 | ]) 52 | }) 53 | 54 | test('skip some nodes', () => { 55 | expect( 56 | new DomIterator(fragment, { 57 | skipSelf: node => node.nodeName === 'TR', 58 | }).toArray(), 59 | ).toStrictEqual([ 60 | fragment, 61 | table, 62 | td1a, 63 | text1a, 64 | td1b, 65 | text1b, 66 | td2a, 67 | text2a, 68 | td2b, 69 | text2b, 70 | img1, 71 | img2, 72 | img3, 73 | ]) 74 | }) 75 | 76 | test('skip children of some nodes', () => { 77 | expect( 78 | new DomIterator(fragment, { 79 | skipChildren: node => node.nodeName === 'TR', 80 | }).toArray(), 81 | ).toStrictEqual([fragment, table, tr1, tr2, img1, img2, img3]) 82 | }) 83 | 84 | test('skip some nodes and their children', () => { 85 | expect( 86 | new DomIterator(fragment, { 87 | skipChildren: node => node.nodeName === 'TR', 88 | skipSelf: node => node.nodeName === 'TR', 89 | }).toArray(), 90 | ).toStrictEqual([fragment, table, img1, img2, img3]) 91 | }) 92 | 93 | test('skip the root node', () => { 94 | expect( 95 | new DomIterator(fragment, { 96 | skipSelf: node => node === fragment, 97 | }).toArray(), 98 | ).toStrictEqual([ 99 | table, 100 | tr1, 101 | td1a, 102 | text1a, 103 | td1b, 104 | text1b, 105 | tr2, 106 | td2a, 107 | text2a, 108 | td2b, 109 | text2b, 110 | img1, 111 | img2, 112 | img3, 113 | ]) 114 | }) 115 | 116 | test('skip the children of the root node', () => { 117 | expect( 118 | new DomIterator(fragment, { 119 | skipChildren: node => node === fragment, 120 | }).toArray(), 121 | ).toStrictEqual([fragment]) 122 | }) 123 | 124 | test('skip the root node and its children', () => { 125 | expect( 126 | new DomIterator(fragment, { 127 | skipChildren: node => node === fragment, 128 | skipSelf: node => node === fragment, 129 | }).toArray(), 130 | ).toStrictEqual([]) 131 | }) 132 | 133 | test('stay within root', () => { 134 | expect(new DomIterator(tr1).toArray()).toStrictEqual([ 135 | tr1, 136 | td1a, 137 | text1a, 138 | td1b, 139 | text1b, 140 | ]) 141 | }) 142 | 143 | describe('forEach', () => { 144 | test('all', () => { 145 | const fn = jest.fn() 146 | const iterator = new DomIterator(tr1) 147 | iterator.forEach(fn) 148 | expect(fn).toHaveBeenCalledTimes(5) 149 | expect(fn).toHaveBeenNthCalledWith(1, tr1) 150 | expect(fn).toHaveBeenNthCalledWith(2, td1a) 151 | expect(fn).toHaveBeenNthCalledWith(3, text1a) 152 | expect(fn).toHaveBeenNthCalledWith(4, td1b) 153 | expect(fn).toHaveBeenNthCalledWith(5, text1b) 154 | }) 155 | test('skip one', () => { 156 | const fn = jest.fn() 157 | const iterator = new DomIterator(tr1) 158 | iterator.next() 159 | iterator.forEach(fn) 160 | expect(fn).toHaveBeenCalledTimes(4) 161 | expect(fn).toHaveBeenNthCalledWith(1, td1a) 162 | expect(fn).toHaveBeenNthCalledWith(2, text1a) 163 | expect(fn).toHaveBeenNthCalledWith(3, td1b) 164 | expect(fn).toHaveBeenNthCalledWith(4, text1b) 165 | }) 166 | test('skip one', () => { 167 | const fn = jest.fn() 168 | const iterator = new DomIterator(tr1) 169 | iterator.next() 170 | iterator.next() 171 | iterator.next() 172 | iterator.next() 173 | iterator.next() 174 | iterator.forEach(fn) 175 | expect(fn).toHaveBeenCalledTimes(0) 176 | }) 177 | }) 178 | 179 | describe('some', () => { 180 | test('false - some nodes', () => { 181 | const fn = jest.fn(() => false) 182 | const iterator = new DomIterator(tr1) 183 | expect(iterator.some(fn)).toBe(false) 184 | expect(fn).toHaveBeenCalledTimes(5) 185 | expect(fn).toHaveBeenNthCalledWith(1, tr1) 186 | expect(fn).toHaveBeenNthCalledWith(2, td1a) 187 | expect(fn).toHaveBeenNthCalledWith(3, text1a) 188 | expect(fn).toHaveBeenNthCalledWith(4, td1b) 189 | expect(fn).toHaveBeenNthCalledWith(5, text1b) 190 | }) 191 | test('false - no nodes', () => { 192 | const fn = jest.fn(() => false) 193 | const iterator = new DomIterator(tr1) 194 | iterator.next() 195 | iterator.next() 196 | iterator.next() 197 | iterator.next() 198 | iterator.next() 199 | expect(iterator.some(fn)).toBe(false) 200 | expect(fn).toHaveBeenCalledTimes(0) 201 | }) 202 | test('true - first node', () => { 203 | let i = 0 204 | const fn = jest.fn(() => i++ === 0) 205 | const iterator = new DomIterator(tr1) 206 | expect(iterator.some(fn)).toBe(true) 207 | expect(fn).toHaveBeenCalledTimes(1) 208 | expect(fn).toHaveBeenNthCalledWith(1, tr1) 209 | }) 210 | test('true - middle node', () => { 211 | let i = 0 212 | const fn = jest.fn(() => i++ === 2) 213 | const iterator = new DomIterator(tr1) 214 | expect(iterator.some(fn)).toBe(true) 215 | expect(fn).toHaveBeenCalledTimes(3) 216 | expect(fn).toHaveBeenNthCalledWith(1, tr1) 217 | expect(fn).toHaveBeenNthCalledWith(2, td1a) 218 | expect(fn).toHaveBeenNthCalledWith(3, text1a) 219 | }) 220 | test('true - last node', () => { 221 | let i = 0 222 | const fn = jest.fn(() => i++ === 4) 223 | const iterator = new DomIterator(tr1) 224 | expect(iterator.some(fn)).toBe(true) 225 | expect(fn).toHaveBeenCalledTimes(5) 226 | expect(fn).toHaveBeenNthCalledWith(1, tr1) 227 | expect(fn).toHaveBeenNthCalledWith(2, td1a) 228 | expect(fn).toHaveBeenNthCalledWith(3, text1a) 229 | expect(fn).toHaveBeenNthCalledWith(4, td1b) 230 | expect(fn).toHaveBeenNthCalledWith(5, text1b) 231 | }) 232 | }) 233 | 234 | describe('reduce', () => { 235 | test('all', () => { 236 | const fn = jest.fn((result, current) => (current ? result + 2 : 0)) 237 | const iterator = new DomIterator(tr1) 238 | expect(iterator.reduce(fn, 5)).toBe(15) 239 | expect(fn).toHaveBeenCalledTimes(5) 240 | expect(fn).toHaveBeenNthCalledWith(1, 5, tr1) 241 | expect(fn).toHaveBeenNthCalledWith(2, 7, td1a) 242 | expect(fn).toHaveBeenNthCalledWith(3, 9, text1a) 243 | expect(fn).toHaveBeenNthCalledWith(4, 11, td1b) 244 | expect(fn).toHaveBeenNthCalledWith(5, 13, text1b) 245 | }) 246 | test('skip one', () => { 247 | const fn = jest.fn((result, current) => (current ? result + 2 : 0)) 248 | const iterator = new DomIterator(tr1) 249 | iterator.next() 250 | expect(iterator.reduce(fn, 5)).toBe(13) 251 | expect(fn).toHaveBeenCalledTimes(4) 252 | expect(fn).toHaveBeenNthCalledWith(1, 5, td1a) 253 | expect(fn).toHaveBeenNthCalledWith(2, 7, text1a) 254 | expect(fn).toHaveBeenNthCalledWith(3, 9, td1b) 255 | expect(fn).toHaveBeenNthCalledWith(4, 11, text1b) 256 | }) 257 | test('skip all', () => { 258 | const fn = jest.fn((result, current) => (current ? result + 2 : 0)) 259 | const iterator = new DomIterator(tr1) 260 | iterator.next() 261 | iterator.next() 262 | iterator.next() 263 | iterator.next() 264 | iterator.next() 265 | expect(iterator.reduce(fn, 5)).toBe(5) 266 | expect(fn).toHaveBeenCalledTimes(0) 267 | }) 268 | }) 269 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Diff, DIFF_EQUAL, diff_match_patch } from 'diff-match-patch' 2 | 3 | export type NodePredicate = (node: Node) => boolean 4 | export type IndefiniteNodePredicate = (node: Node) => boolean | undefined 5 | export type DiffTextType = (oldText: string, newText: string) => Diff[] 6 | 7 | export function isElement(node: Node): node is HTMLElement { 8 | return node.nodeType === node.ELEMENT_NODE 9 | } 10 | 11 | export function isText(node: Node): node is Text { 12 | return node.nodeType === node.TEXT_NODE 13 | } 14 | 15 | export function isDocument(node: Node): node is Document { 16 | return node.nodeType === node.DOCUMENT_NODE 17 | } 18 | 19 | export function isDocumentFragment(node: Node): node is DocumentFragment { 20 | return node.nodeType === node.DOCUMENT_FRAGMENT_NODE 21 | } 22 | 23 | export function isComment(node: Node): node is Comment { 24 | return node.nodeType === node.COMMENT_NODE 25 | } 26 | 27 | export type Comparator = (item1: T, item2: T) => boolean 28 | 29 | export function strictEqual(item1: T, item2: T): boolean { 30 | return item1 === item2 31 | } 32 | 33 | export function areArraysEqual( 34 | array1: T[], 35 | array2: T[], 36 | comparator: Comparator = strictEqual, 37 | ): boolean { 38 | if (array1.length !== array2.length) { 39 | return false 40 | } 41 | 42 | for (let i = 0, l = array1.length; i < l; ++i) { 43 | if (!comparator(array1[i], array2[i])) { 44 | return false 45 | } 46 | } 47 | 48 | return true 49 | } 50 | 51 | function getAttributeNames(element: Element): string[] { 52 | if (element.getAttributeNames) { 53 | return element.getAttributeNames() 54 | } else { 55 | const attributes = element.attributes 56 | const length = attributes.length 57 | const attributeNames = new Array(length) 58 | 59 | for (let i = 0; i < length; i++) { 60 | attributeNames[i] = attributes[i].name 61 | } 62 | 63 | return attributeNames 64 | } 65 | } 66 | 67 | function stripNewlines(nodes: NodeList): Node[] { 68 | return Array.from(nodes).filter((node: Node) => { 69 | return ( 70 | node.nodeName !== '#text' || 71 | (node.textContent && node.textContent.trim() !== '') 72 | ) 73 | }) 74 | } 75 | 76 | /** 77 | * Compares DOM nodes for equality. 78 | * @param node1 The first node to compare. 79 | * @param node2 The second node to compare. 80 | * @param deep If true, the child nodes are compared recursively too. 81 | * @returns `true`, if the 2 nodes are equal, otherwise `false`. 82 | */ 83 | export function areNodesEqual( 84 | node1: Node, 85 | node2: Node, 86 | deep: boolean = false, 87 | ): boolean { 88 | if (node1 === node2) { 89 | return true 90 | } 91 | 92 | if ( 93 | node1.nodeType !== node2.nodeType || 94 | node1.nodeName !== node2.nodeName 95 | ) { 96 | return false 97 | } 98 | 99 | if (isText(node1) || isComment(node1)) { 100 | if (node1.data !== (node2 as typeof node1).data) { 101 | return false 102 | } 103 | } else if (isElement(node1)) { 104 | const attributeNames1 = getAttributeNames(node1).sort() 105 | const attributeNames2 = getAttributeNames(node2 as typeof node1).sort() 106 | 107 | if (!areArraysEqual(attributeNames1, attributeNames2)) { 108 | return false 109 | } 110 | 111 | for (let i = 0, l = attributeNames1.length; i < l; ++i) { 112 | const name = attributeNames1[i] 113 | const value1 = node1.getAttribute(name) 114 | const value2 = (node2 as typeof node1).getAttribute(name) 115 | 116 | if (value1 !== value2) { 117 | return false 118 | } 119 | } 120 | } 121 | 122 | if (deep) { 123 | const childNodes1 = node1.childNodes 124 | const childNodes2 = node2.childNodes 125 | 126 | if (childNodes1.length !== childNodes2.length) { 127 | return false 128 | } 129 | 130 | for (let i = 0, l = childNodes1.length; i < l; ++i) { 131 | if (!areNodesEqual(childNodes1[i], childNodes2[i], deep)) { 132 | return false 133 | } 134 | } 135 | } 136 | 137 | return true 138 | } 139 | 140 | /** 141 | * Gets a list of `node`'s ancestor nodes up until and including `rootNode`. 142 | * @param node Node whose ancestors to get. 143 | * @param rootNode The root node. 144 | */ 145 | export function getAncestors(node: Node, rootNode: Node | null = null): Node[] { 146 | if (!node || node === rootNode) { 147 | return [] 148 | } 149 | 150 | const ancestors = [] 151 | let currentNode: Node | null = node.parentNode 152 | 153 | while (currentNode) { 154 | ancestors.push(currentNode) 155 | if (currentNode === rootNode) { 156 | break 157 | } 158 | currentNode = currentNode.parentNode 159 | } 160 | 161 | return ancestors 162 | } 163 | 164 | export function never( 165 | message: string = 'visual-dom-diff: Should never happen', 166 | ): never { 167 | throw new Error(message) 168 | } 169 | 170 | // Source: https://stackoverflow.com/a/7616484/706807 (simplified) 171 | export function hashCode(str: string): number { 172 | let hash = 0 173 | for (let i = 0; i < str.length; i++) { 174 | // tslint:disable-next-line:no-bitwise 175 | hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 176 | } 177 | return hash 178 | } 179 | 180 | /** 181 | * Returns a single character which should replace the given node name 182 | * when serializing a non-text node. 183 | */ 184 | export function charForNodeName(nodeName: string): string { 185 | return String.fromCharCode( 186 | 0xe000 + (hashCode(nodeName) % (0xf900 - 0xe000)), 187 | ) 188 | } 189 | 190 | /** 191 | * Moves trailing HTML tag markers in the DIFF_INSERT and DIFF_DELETE diff items to the front, 192 | * if possible, in order to improve quality of the DOM diff. 193 | */ 194 | export function cleanUpNodeMarkers(diff: Diff[]): void { 195 | for (let i = 0; i < diff.length - 2; ) { 196 | const diff0 = diff[i] 197 | const diff1 = diff[i + 1] 198 | const diff2 = diff[i + 2] 199 | 200 | if ( 201 | diff0[0] !== DIFF_EQUAL || 202 | diff1[0] === DIFF_EQUAL || 203 | diff2[0] !== DIFF_EQUAL 204 | ) { 205 | i++ 206 | continue 207 | } 208 | 209 | const string0 = diff0[1] 210 | const string1 = diff1[1] 211 | const string2 = diff2[1] 212 | const lastChar0 = string0[string0.length - 1] 213 | const lastChar1 = string1[string1.length - 1] 214 | 215 | if ( 216 | lastChar0 !== lastChar1 || 217 | lastChar0 < '\uE000' || 218 | lastChar0 >= '\uF900' 219 | ) { 220 | i++ 221 | continue 222 | } 223 | 224 | diff0[1] = string0.substring(0, string0.length - 1) 225 | diff1[1] = lastChar0 + string1.substring(0, string1.length - 1) 226 | diff2[1] = lastChar0 + string2 227 | 228 | if (diff0[1].length === 0) { 229 | diff.splice(i, 1) 230 | } 231 | } 232 | } 233 | 234 | const dmp = new diff_match_patch() 235 | 236 | /** 237 | * Diffs the 2 strings and cleans up the result before returning it. 238 | */ 239 | export function diffText(oldText: string, newText: string): Diff[] { 240 | const diff = dmp.diff_main(oldText, newText) 241 | const result: Diff[] = [] 242 | const temp: Diff[] = [] 243 | 244 | cleanUpNodeMarkers(diff) 245 | 246 | // Execute `dmp.diff_cleanupSemantic` excluding equal node markers. 247 | for (let i = 0, l = diff.length; i < l; ++i) { 248 | const item = diff[i] 249 | 250 | if (item[0] === DIFF_EQUAL) { 251 | const text = item[1] 252 | const totalLength = text.length 253 | const prefixLength = /^[^\uE000-\uF8FF]*/.exec(text)![0].length 254 | 255 | if (prefixLength < totalLength) { 256 | const suffixLength = /[^\uE000-\uF8FF]*$/.exec(text)![0].length 257 | 258 | if (prefixLength > 0) { 259 | temp.push([DIFF_EQUAL, text.substring(0, prefixLength)]) 260 | } 261 | 262 | dmp.diff_cleanupSemantic(temp) 263 | pushAll(result, temp) 264 | temp.length = 0 265 | 266 | result.push([ 267 | DIFF_EQUAL, 268 | text.substring(prefixLength, totalLength - suffixLength), 269 | ]) 270 | 271 | if (suffixLength > 0) { 272 | temp.push([ 273 | DIFF_EQUAL, 274 | text.substring(totalLength - suffixLength), 275 | ]) 276 | } 277 | } else { 278 | temp.push(item) 279 | } 280 | } else { 281 | temp.push(item) 282 | } 283 | } 284 | 285 | dmp.diff_cleanupSemantic(temp) 286 | pushAll(result, temp) 287 | temp.length = 0 288 | 289 | dmp.diff_cleanupMerge(result) 290 | cleanUpNodeMarkers(result) 291 | return result 292 | } 293 | 294 | function pushAll(array: T[], items: T[]): void { 295 | let destination = array.length 296 | let source = 0 297 | const length = items.length 298 | 299 | while (source < length) { 300 | array[destination++] = items[source++] 301 | } 302 | } 303 | 304 | export function markUpNode( 305 | node: Node, 306 | elementName: string, 307 | className: string, 308 | ): void { 309 | const document = node.ownerDocument! 310 | const parentNode = node.parentNode! 311 | const previousSibling = node.previousSibling 312 | 313 | if (isElement(node)) { 314 | node.classList.add(className) 315 | } else if ( 316 | previousSibling && 317 | previousSibling.nodeName === elementName && 318 | (previousSibling as Element).classList.contains(className) 319 | ) { 320 | previousSibling.appendChild(node) 321 | } else { 322 | const wrapper = document.createElement(elementName) 323 | wrapper.classList.add(className) 324 | parentNode.insertBefore(wrapper, node) 325 | wrapper.appendChild(node) 326 | } 327 | } 328 | 329 | export function isTableValid(table: Node, verifyColumns: boolean): boolean { 330 | let columnCount: number | undefined 331 | return validateTable(table) 332 | 333 | function validateTable({ childNodes }: Node): boolean { 334 | const filteredChildNodes = stripNewlines(childNodes) 335 | const l = filteredChildNodes.length 336 | let i = 0 337 | 338 | if (i < l && filteredChildNodes[i].nodeName === 'CAPTION') { 339 | i++ 340 | } 341 | 342 | if (i < l && filteredChildNodes[i].nodeName === 'THEAD') { 343 | if (!validateRowGroup(filteredChildNodes[i])) { 344 | return false 345 | } 346 | i++ 347 | } 348 | 349 | if (i < l && filteredChildNodes[i].nodeName === 'TBODY') { 350 | if (!validateRowGroup(filteredChildNodes[i])) { 351 | return false 352 | } 353 | i++ 354 | } else { 355 | return false 356 | } 357 | 358 | if (i < l && filteredChildNodes[i].nodeName === 'TFOOT') { 359 | if (!validateRowGroup(filteredChildNodes[i])) { 360 | return false 361 | } 362 | i++ 363 | } 364 | 365 | return i === l 366 | } 367 | 368 | function validateRowGroup({ childNodes, nodeName }: Node): boolean { 369 | const filteredChildNodes = stripNewlines(childNodes) 370 | if (nodeName === 'TBODY' && filteredChildNodes.length === 0) { 371 | return false 372 | } 373 | for (let i = 0, l = filteredChildNodes.length; i < l; ++i) { 374 | if (!validateRow(filteredChildNodes[i])) { 375 | return false 376 | } 377 | } 378 | return true 379 | } 380 | 381 | function validateRow({ childNodes, nodeName }: Node): boolean { 382 | const filteredChildNodes = stripNewlines(childNodes) 383 | if (nodeName !== 'TR' || filteredChildNodes.length === 0) { 384 | return false 385 | } 386 | if (verifyColumns) { 387 | if (columnCount === undefined) { 388 | columnCount = filteredChildNodes.length 389 | } else if (columnCount !== filteredChildNodes.length) { 390 | return false 391 | } 392 | } 393 | for (let i = 0, l = filteredChildNodes.length; i < l; ++i) { 394 | if (!validateCell(filteredChildNodes[i])) { 395 | return false 396 | } 397 | } 398 | return true 399 | } 400 | 401 | function validateCell(node: Node): boolean { 402 | const { nodeName } = node 403 | if (nodeName !== 'TD' && nodeName !== 'TH') { 404 | return false 405 | } 406 | const cell = node as Element 407 | const colspan = cell.getAttribute('colspan') 408 | const rowspan = cell.getAttribute('rowspan') 409 | return ( 410 | (colspan === null || colspan === '1') && 411 | (rowspan === null || rowspan === '1') 412 | ) 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/diff.ts: -------------------------------------------------------------------------------- 1 | import { Diff, DIFF_DELETE, DIFF_INSERT } from 'diff-match-patch' 2 | import { Config, Options, optionsToConfig } from './config' 3 | import { DomIterator } from './domIterator' 4 | import { 5 | areNodesEqual, 6 | charForNodeName, 7 | getAncestors, 8 | isElement, 9 | isTableValid, 10 | isText, 11 | markUpNode, 12 | never, 13 | } from './util' 14 | 15 | /** 16 | * A simple helper which allows us to treat TH as TD in certain situations. 17 | */ 18 | const nodeNameOverride = (nodeName: string): string => { 19 | return nodeName === 'TH' ? 'TD' : nodeName 20 | } 21 | 22 | /** 23 | * Stringifies a DOM node recursively. Text nodes are represented by their `data`, 24 | * while all other nodes are represented by a single Unicode code point 25 | * from the Private Use Area of the Basic Multilingual Plane. 26 | */ 27 | const serialize = (root: Node, config: Config): string => 28 | new DomIterator(root, config).reduce( 29 | (text, node) => 30 | text + 31 | (isText(node) 32 | ? node.data 33 | : charForNodeName(nodeNameOverride(node.nodeName))), 34 | '', 35 | ) 36 | 37 | const getLength = (node: Node): number => (isText(node) ? node.length : 1) 38 | const isTr = (node: Node): boolean => node.nodeName === 'TR' 39 | const isNotTr = (node: Node): boolean => !isTr(node) 40 | const trIteratorOptions = { 41 | skipChildren: isTr, 42 | skipSelf: isNotTr, 43 | } 44 | 45 | export { Options as VisualDomDiffOptions } from './config' 46 | export function visualDomDiff( 47 | oldRootNode: Node, 48 | newRootNode: Node, 49 | options: Options = {}, 50 | ): DocumentFragment { 51 | // Define config and simple helpers. 52 | const document = newRootNode.ownerDocument || (newRootNode as Document) 53 | const config = optionsToConfig(options) 54 | const { 55 | addedClass, 56 | diffText, 57 | modifiedClass, 58 | removedClass, 59 | skipSelf, 60 | skipChildren, 61 | } = config 62 | const notSkipSelf = (node: Node): boolean => !skipSelf(node) 63 | const getDepth = (node: Node, rootNode: Node): number => 64 | getAncestors(node, rootNode).filter(notSkipSelf).length 65 | const isFormattingNode = (node: Node): boolean => 66 | isElement(node) && skipSelf(node) 67 | const getFormattingAncestors = (node: Node, rootNode: Node): Node[] => 68 | getAncestors(node, rootNode) 69 | .filter(isFormattingNode) 70 | .reverse() 71 | const getColumnValue = (node: Node) => 72 | addedNodes.has(node) ? 1 : removedNodes.has(node) ? -1 : 0 73 | 74 | // Input iterators. 75 | const diffArray = diffText( 76 | serialize(oldRootNode, config), 77 | serialize(newRootNode, config), 78 | ) 79 | let diffIndex = 0 80 | const oldIterator = new DomIterator(oldRootNode, config) 81 | const newIterator = new DomIterator(newRootNode, config) 82 | 83 | // Input variables produced by the input iterators. 84 | let oldDone: boolean | undefined 85 | let newDone: boolean | undefined 86 | let diffItem: Diff 87 | let oldNode: Node 88 | let newNode: Node 89 | let diffOffset = 0 90 | let oldOffset = 0 91 | let newOffset = 0 92 | diffItem = diffArray[diffIndex++] 93 | ;({ done: oldDone, value: oldNode } = oldIterator.next()) 94 | ;({ done: newDone, value: newNode } = newIterator.next()) 95 | 96 | // Output variables. 97 | const rootOutputNode = document.createDocumentFragment() 98 | let oldOutputNode: Node = rootOutputNode 99 | let oldOutputDepth = 0 100 | let newOutputNode: Node = rootOutputNode 101 | let newOutputDepth = 0 102 | let removedNode: Node | null = null 103 | let addedNode: Node | null = null 104 | const removedNodes = new Set() 105 | const addedNodes = new Set() 106 | const modifiedNodes = new Set() 107 | const formattingMap = new Map() 108 | const equalTables = new Array<{ 109 | oldTable: Node 110 | newTable: Node 111 | outputTable: Node 112 | }>() 113 | const equalRows = new Map< 114 | Node, // outputRow 115 | { 116 | oldRow: Node 117 | newRow: Node 118 | } 119 | >() 120 | 121 | function prepareOldOutput(): void { 122 | const depth = getDepth(oldNode, oldRootNode) 123 | while (oldOutputDepth > depth) { 124 | /* istanbul ignore if */ 125 | if (!oldOutputNode.parentNode) { 126 | return never() 127 | } 128 | if (oldOutputNode === removedNode) { 129 | removedNode = null 130 | } 131 | oldOutputNode = oldOutputNode.parentNode 132 | oldOutputDepth-- 133 | } 134 | 135 | /* istanbul ignore if */ 136 | if (oldOutputDepth !== depth) { 137 | return never() 138 | } 139 | } 140 | 141 | function prepareNewOutput(): void { 142 | const depth = getDepth(newNode, newRootNode) 143 | while (newOutputDepth > depth) { 144 | /* istanbul ignore if */ 145 | if (!newOutputNode.parentNode) { 146 | return never() 147 | } 148 | if (newOutputNode === addedNode) { 149 | addedNode = null 150 | } 151 | newOutputNode = newOutputNode.parentNode 152 | newOutputDepth-- 153 | } 154 | 155 | /* istanbul ignore if */ 156 | if (newOutputDepth !== depth) { 157 | return never() 158 | } 159 | } 160 | 161 | function appendCommonChild(node: Node): void { 162 | /* istanbul ignore if */ 163 | if (oldOutputNode !== newOutputNode || addedNode || removedNode) { 164 | return never() 165 | } 166 | 167 | if (isText(node)) { 168 | const oldFormatting = getFormattingAncestors(oldNode, oldRootNode) 169 | const newFormatting = getFormattingAncestors(newNode, newRootNode) 170 | formattingMap.set(node, newFormatting) 171 | 172 | const length = oldFormatting.length 173 | if (length !== newFormatting.length) { 174 | modifiedNodes.add(node) 175 | } else { 176 | for (let i = 0; i < length; ++i) { 177 | if (!areNodesEqual(oldFormatting[i], newFormatting[i])) { 178 | modifiedNodes.add(node) 179 | break 180 | } 181 | } 182 | } 183 | } else { 184 | if (!areNodesEqual(oldNode, newNode)) { 185 | modifiedNodes.add(node) 186 | } 187 | 188 | const nodeName = oldNode.nodeName 189 | if (nodeName === 'TABLE') { 190 | equalTables.push({ 191 | newTable: newNode, 192 | oldTable: oldNode, 193 | outputTable: node, 194 | }) 195 | } else if (nodeName === 'TR') { 196 | equalRows.set(node, { 197 | newRow: newNode, 198 | oldRow: oldNode, 199 | }) 200 | } 201 | } 202 | 203 | newOutputNode.appendChild(node) 204 | oldOutputNode = node 205 | newOutputNode = node 206 | oldOutputDepth++ 207 | newOutputDepth++ 208 | } 209 | 210 | function appendOldChild(node: Node): void { 211 | if (!removedNode) { 212 | removedNode = node 213 | removedNodes.add(node) 214 | } 215 | 216 | if (isText(node)) { 217 | const oldFormatting = getFormattingAncestors(oldNode, oldRootNode) 218 | formattingMap.set(node, oldFormatting) 219 | } 220 | 221 | oldOutputNode.appendChild(node) 222 | oldOutputNode = node 223 | oldOutputDepth++ 224 | } 225 | 226 | function appendNewChild(node: Node): void { 227 | if (!addedNode) { 228 | addedNode = node 229 | addedNodes.add(node) 230 | } 231 | 232 | if (isText(node)) { 233 | const newFormatting = getFormattingAncestors(newNode, newRootNode) 234 | formattingMap.set(node, newFormatting) 235 | } 236 | 237 | newOutputNode.appendChild(node) 238 | newOutputNode = node 239 | newOutputDepth++ 240 | } 241 | 242 | function nextDiff(step: number): void { 243 | const length = diffItem[1].length 244 | diffOffset += step 245 | if (diffOffset === length) { 246 | diffItem = diffArray[diffIndex++] 247 | diffOffset = 0 248 | } else { 249 | /* istanbul ignore if */ 250 | if (diffOffset > length) { 251 | return never() 252 | } 253 | } 254 | } 255 | 256 | function nextOld(step: number): void { 257 | const length = getLength(oldNode) 258 | oldOffset += step 259 | if (oldOffset === length) { 260 | ;({ done: oldDone, value: oldNode } = oldIterator.next()) 261 | oldOffset = 0 262 | } else { 263 | /* istanbul ignore if */ 264 | if (oldOffset > length) { 265 | return never() 266 | } 267 | } 268 | } 269 | 270 | function nextNew(step: number): void { 271 | const length = getLength(newNode) 272 | newOffset += step 273 | if (newOffset === length) { 274 | ;({ done: newDone, value: newNode } = newIterator.next()) 275 | newOffset = 0 276 | } else { 277 | /* istanbul ignore if */ 278 | if (newOffset > length) { 279 | return never() 280 | } 281 | } 282 | } 283 | 284 | // Copy all content from oldRootNode and newRootNode to rootOutputNode, 285 | // while deduplicating identical content. 286 | // Difference markers and formatting are excluded at this stage. 287 | while (diffItem) { 288 | if (diffItem[0] === DIFF_DELETE) { 289 | /* istanbul ignore if */ 290 | if (oldDone) { 291 | return never() 292 | } 293 | 294 | prepareOldOutput() 295 | 296 | const length = Math.min( 297 | diffItem[1].length - diffOffset, 298 | getLength(oldNode) - oldOffset, 299 | ) 300 | const text = diffItem[1].substring(diffOffset, diffOffset + length) 301 | 302 | appendOldChild( 303 | isText(oldNode) 304 | ? document.createTextNode(text) 305 | : oldNode.cloneNode(false), 306 | ) 307 | 308 | nextDiff(length) 309 | nextOld(length) 310 | } else if (diffItem[0] === DIFF_INSERT) { 311 | /* istanbul ignore if */ 312 | if (newDone) { 313 | return never() 314 | } 315 | 316 | prepareNewOutput() 317 | 318 | const length = Math.min( 319 | diffItem[1].length - diffOffset, 320 | getLength(newNode) - newOffset, 321 | ) 322 | const text = diffItem[1].substring(diffOffset, diffOffset + length) 323 | 324 | appendNewChild( 325 | isText(newNode) 326 | ? document.createTextNode(text) 327 | : newNode.cloneNode(false), 328 | ) 329 | 330 | nextDiff(length) 331 | nextNew(length) 332 | } else { 333 | /* istanbul ignore if */ 334 | if (oldDone || newDone) { 335 | return never() 336 | } 337 | 338 | prepareOldOutput() 339 | prepareNewOutput() 340 | 341 | const length = Math.min( 342 | diffItem[1].length - diffOffset, 343 | getLength(oldNode) - oldOffset, 344 | getLength(newNode) - newOffset, 345 | ) 346 | const text = diffItem[1].substring(diffOffset, diffOffset + length) 347 | 348 | if ( 349 | oldOutputNode === newOutputNode && 350 | ((isText(oldNode) && isText(newNode)) || 351 | (nodeNameOverride(oldNode.nodeName) === 352 | nodeNameOverride(newNode.nodeName) && 353 | !skipChildren(oldNode) && 354 | !skipChildren(newNode)) || 355 | areNodesEqual(oldNode, newNode)) 356 | ) { 357 | appendCommonChild( 358 | isText(newNode) 359 | ? document.createTextNode(text) 360 | : newNode.cloneNode(false), 361 | ) 362 | } else { 363 | appendOldChild( 364 | isText(oldNode) 365 | ? document.createTextNode(text) 366 | : oldNode.cloneNode(false), 367 | ) 368 | appendNewChild( 369 | isText(newNode) 370 | ? document.createTextNode(text) 371 | : newNode.cloneNode(false), 372 | ) 373 | } 374 | 375 | nextDiff(length) 376 | nextOld(length) 377 | nextNew(length) 378 | } 379 | } 380 | 381 | // Move deletes before inserts. 382 | removedNodes.forEach(node => { 383 | const parentNode = node.parentNode as Node 384 | let previousSibling = node.previousSibling 385 | 386 | while (previousSibling && addedNodes.has(previousSibling)) { 387 | parentNode.insertBefore(node, previousSibling) 388 | previousSibling = node.previousSibling 389 | } 390 | }) 391 | 392 | // Ensure a user friendly result for tables. 393 | equalTables.forEach(equalTable => { 394 | const { newTable, oldTable, outputTable } = equalTable 395 | // Handle tables which can't be diffed nicely. 396 | if ( 397 | !isTableValid(oldTable, true) || 398 | !isTableValid(newTable, true) || 399 | !isTableValid(outputTable, false) 400 | ) { 401 | // Remove all values which were previously recorded for outputTable. 402 | new DomIterator(outputTable).forEach(node => { 403 | addedNodes.delete(node) 404 | removedNodes.delete(node) 405 | modifiedNodes.delete(node) 406 | formattingMap.delete(node) 407 | }) 408 | 409 | // Display both the old and new table. 410 | const parentNode = outputTable.parentNode! 411 | const oldTableClone = oldTable.cloneNode(true) 412 | const newTableClone = newTable.cloneNode(true) 413 | parentNode.insertBefore(oldTableClone, outputTable) 414 | parentNode.insertBefore(newTableClone, outputTable) 415 | parentNode.removeChild(outputTable) 416 | removedNodes.add(oldTableClone) 417 | addedNodes.add(newTableClone) 418 | return 419 | } 420 | 421 | // Figure out which columns have been added or removed 422 | // based on the first row appearing in both tables. 423 | // 424 | // - 1: column added 425 | // - 0: column equal 426 | // - -1: column removed 427 | const columns: number[] = [] 428 | 429 | new DomIterator(outputTable, trIteratorOptions).some(row => { 430 | const diffedRows = equalRows.get(row) 431 | if (!diffedRows) { 432 | return false 433 | } 434 | const { oldRow, newRow } = diffedRows 435 | const oldColumnCount = oldRow.childNodes.length 436 | const newColumnCount = newRow.childNodes.length 437 | const maxColumnCount = Math.max(oldColumnCount, newColumnCount) 438 | const minColumnCount = Math.min(oldColumnCount, newColumnCount) 439 | 440 | if (row.childNodes.length === maxColumnCount) { 441 | // The generic diff algorithm worked properly in this case, 442 | // so we can rely on its results. 443 | const cells = row.childNodes 444 | for (let i = 0, l = cells.length; i < l; ++i) { 445 | columns.push(getColumnValue(cells[i])) 446 | } 447 | } else { 448 | // Fallback to a simple but correct algorithm. 449 | let i = 0 450 | let columnValue = 0 451 | while (i < minColumnCount) { 452 | columns[i++] = columnValue 453 | } 454 | columnValue = oldColumnCount < newColumnCount ? 1 : -1 455 | while (i < maxColumnCount) { 456 | columns[i++] = columnValue 457 | } 458 | } 459 | 460 | return true 461 | }) 462 | const columnCount = columns.length 463 | /* istanbul ignore if */ 464 | if (columnCount === 0) { 465 | return never() 466 | } 467 | 468 | // Fix up the rows which do not align with `columns`. 469 | new DomIterator(outputTable, trIteratorOptions).forEach(row => { 470 | const cells = row.childNodes 471 | 472 | if (addedNodes.has(row) || addedNodes.has(row.parentNode!)) { 473 | if (cells.length < columnCount) { 474 | for (let i = 0; i < columnCount; ++i) { 475 | if (columns[i] === -1) { 476 | const td = document.createElement('TD') 477 | row.insertBefore(td, cells[i]) 478 | removedNodes.add(td) 479 | } 480 | } 481 | } 482 | } else if ( 483 | removedNodes.has(row) || 484 | removedNodes.has(row.parentNode!) 485 | ) { 486 | if (cells.length < columnCount) { 487 | for (let i = 0; i < columnCount; ++i) { 488 | if (columns[i] === 1) { 489 | const td = document.createElement('TD') 490 | row.insertBefore(td, cells[i]) 491 | } 492 | } 493 | } 494 | } else { 495 | // Check, if the columns in this row are aligned with those in the reference row. 496 | let isAligned = true 497 | for (let i = 0, l = cells.length; i < l; ++i) { 498 | if (getColumnValue(cells[i]) !== columns[i]) { 499 | isAligned = false 500 | break 501 | } 502 | } 503 | 504 | if (!isAligned) { 505 | // Remove all values which were previously recorded for row's content. 506 | const iterator = new DomIterator(row) 507 | iterator.next() // Skip the row itself. 508 | iterator.forEach(node => { 509 | addedNodes.delete(node) 510 | removedNodes.delete(node) 511 | modifiedNodes.delete(node) 512 | formattingMap.delete(node) 513 | }) 514 | 515 | // Remove the row's content. 516 | while (row.firstChild) { 517 | row.removeChild(row.firstChild) 518 | } 519 | 520 | // Diff the individual cells. 521 | const { newRow, oldRow } = equalRows.get(row)! 522 | const newCells = newRow.childNodes 523 | const oldCells = oldRow.childNodes 524 | let oldIndex = 0 525 | let newIndex = 0 526 | for (let i = 0; i < columnCount; ++i) { 527 | if (columns[i] === 1) { 528 | const newCellClone = newCells[newIndex++].cloneNode( 529 | true, 530 | ) 531 | row.appendChild(newCellClone) 532 | addedNodes.add(newCellClone) 533 | } else if (columns[i] === -1) { 534 | const oldCellClone = oldCells[oldIndex++].cloneNode( 535 | true, 536 | ) 537 | row.appendChild(oldCellClone) 538 | removedNodes.add(oldCellClone) 539 | } else { 540 | row.appendChild( 541 | visualDomDiff( 542 | oldCells[oldIndex++], 543 | newCells[newIndex++], 544 | options, 545 | ), 546 | ) 547 | } 548 | } 549 | } 550 | } 551 | }) 552 | 553 | return 554 | }) 555 | 556 | // Mark up the content which has been removed. 557 | removedNodes.forEach(node => { 558 | markUpNode(node, 'DEL', removedClass) 559 | }) 560 | 561 | // Mark up the content which has been added. 562 | addedNodes.forEach(node => { 563 | markUpNode(node, 'INS', addedClass) 564 | }) 565 | 566 | // Mark up the content which has been modified. 567 | if (!config.skipModified) { 568 | modifiedNodes.forEach(modifiedNode => { 569 | markUpNode(modifiedNode, 'INS', modifiedClass) 570 | }) 571 | } 572 | 573 | // Add formatting. 574 | formattingMap.forEach((formattingNodes, textNode) => { 575 | formattingNodes.forEach(formattingNode => { 576 | const parentNode = textNode.parentNode as Node 577 | const previousSibling = textNode.previousSibling 578 | 579 | if ( 580 | previousSibling && 581 | areNodesEqual(previousSibling, formattingNode) 582 | ) { 583 | previousSibling.appendChild(textNode) 584 | } else { 585 | const clonedFormattingNode = formattingNode.cloneNode(false) 586 | parentNode.insertBefore(clonedFormattingNode, textNode) 587 | clonedFormattingNode.appendChild(textNode) 588 | } 589 | }) 590 | }) 591 | 592 | return rootOutputNode 593 | } 594 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from 'diff-match-patch' 2 | import { JSDOM } from 'jsdom' 3 | import { 4 | areArraysEqual, 5 | areNodesEqual, 6 | charForNodeName, 7 | cleanUpNodeMarkers, 8 | diffText, 9 | getAncestors, 10 | isComment, 11 | isDocument, 12 | isDocumentFragment, 13 | isElement, 14 | isText, 15 | never, 16 | } from './util' 17 | 18 | const window = new JSDOM('').window 19 | const document = window.document 20 | const text = document.createTextNode('text') 21 | const identicalText = document.createTextNode('text') 22 | const differentText = document.createTextNode('different text') 23 | const span = document.createElement('SPAN') 24 | const identicalSpan = document.createElement('SPAN') 25 | const differentAttributeNamesSpan = document.createElement('SPAN') 26 | const differentAttributeValuesSpan = document.createElement('SPAN') 27 | const differentChildNodesSpan = document.createElement('SPAN') 28 | const video = document.createElement('VIDEO') 29 | const comment = document.createComment('comment') 30 | const identicalComment = document.createComment('comment') 31 | const differentComment = document.createComment('different comment') 32 | const fragment = document.createDocumentFragment() 33 | const anotherFragment = document.createDocumentFragment() 34 | const pChar = charForNodeName('P') 35 | const ulChar = charForNodeName('UL') 36 | const liChar = charForNodeName('LI') 37 | 38 | span.setAttribute('data-a', 'a') 39 | span.setAttribute('data-b', 'b') 40 | identicalSpan.setAttribute('data-b', 'b') 41 | identicalSpan.setAttribute('data-a', 'a') 42 | differentAttributeNamesSpan.setAttribute('data-a', 'a') 43 | differentAttributeNamesSpan.setAttribute('data-b', 'b') 44 | differentAttributeNamesSpan.setAttribute('data-c', 'c') 45 | differentAttributeValuesSpan.setAttribute('data-a', 'different a') 46 | differentAttributeValuesSpan.setAttribute('data-b', 'different b') 47 | differentChildNodesSpan.setAttribute('data-a', 'a') 48 | differentChildNodesSpan.setAttribute('data-b', 'b') 49 | differentChildNodesSpan.appendChild(document.createTextNode('different')) 50 | 51 | describe('isText', () => { 52 | test('return true given a text node', () => { 53 | expect(isText(text)).toBe(true) 54 | }) 55 | test('return false given a SPAN', () => { 56 | expect(isText(span)).toBe(false) 57 | }) 58 | test('return false given a document', () => { 59 | expect(isText(document)).toBe(false) 60 | }) 61 | test('return false given a document fragment', () => { 62 | expect(isText(fragment)).toBe(false) 63 | }) 64 | test('return false given a comment', () => { 65 | expect(isText(comment)).toBe(false) 66 | }) 67 | }) 68 | 69 | describe('isElement', () => { 70 | test('return false given a text node', () => { 71 | expect(isElement(text)).toBe(false) 72 | }) 73 | test('return true given a SPAN', () => { 74 | expect(isElement(span)).toBe(true) 75 | }) 76 | test('return false given a document', () => { 77 | expect(isElement(document)).toBe(false) 78 | }) 79 | test('return false given a document fragment', () => { 80 | expect(isElement(fragment)).toBe(false) 81 | }) 82 | test('return false given a comment', () => { 83 | expect(isElement(comment)).toBe(false) 84 | }) 85 | }) 86 | 87 | describe('isDocument', () => { 88 | test('return false given a text node', () => { 89 | expect(isDocument(text)).toBe(false) 90 | }) 91 | test('return false given a SPAN', () => { 92 | expect(isDocument(span)).toBe(false) 93 | }) 94 | test('return true given a document', () => { 95 | expect(isDocument(document)).toBe(true) 96 | expect(isDocument(new JSDOM('').window.document)).toBe(true) 97 | }) 98 | test('return false given a document fragment', () => { 99 | expect(isDocument(fragment)).toBe(false) 100 | }) 101 | test('return false given a comment', () => { 102 | expect(isDocument(comment)).toBe(false) 103 | }) 104 | }) 105 | 106 | describe('isDocumentFragment', () => { 107 | test('return false given a text node', () => { 108 | expect(isDocumentFragment(text)).toBe(false) 109 | }) 110 | test('return false given a SPAN', () => { 111 | expect(isDocumentFragment(span)).toBe(false) 112 | }) 113 | test('return false given a document', () => { 114 | expect(isDocumentFragment(document)).toBe(false) 115 | }) 116 | test('return true given a document fragment', () => { 117 | expect(isDocumentFragment(fragment)).toBe(true) 118 | }) 119 | test('return false given a comment', () => { 120 | expect(isDocumentFragment(comment)).toBe(false) 121 | }) 122 | }) 123 | 124 | describe('isComment', () => { 125 | test('return false given a text node', () => { 126 | expect(isComment(text)).toBe(false) 127 | }) 128 | test('return false given a SPAN', () => { 129 | expect(isComment(span)).toBe(false) 130 | }) 131 | test('return false given a document', () => { 132 | expect(isComment(document)).toBe(false) 133 | }) 134 | test('return true given a document fragment', () => { 135 | expect(isComment(fragment)).toBe(false) 136 | }) 137 | test('return true given a comment', () => { 138 | expect(isComment(comment)).toBe(true) 139 | }) 140 | }) 141 | 142 | describe('areArraysEqual', () => { 143 | describe('default comparator', () => { 144 | test('empty arrays', () => { 145 | expect(areArraysEqual([], [])).toBe(true) 146 | }) 147 | test('different length', () => { 148 | expect(areArraysEqual([1], [1, 2])).toBe(false) 149 | }) 150 | test('different item types', () => { 151 | expect(areArraysEqual([1, 2], [1, '2'])).toBe(false) 152 | }) 153 | test('identical arrays', () => { 154 | expect(areArraysEqual([1, '2', text], [1, '2', text])).toBe(true) 155 | }) 156 | test('the same nodes', () => { 157 | expect(areArraysEqual([text, text], [text, text])).toBe(true) 158 | }) 159 | test('identical nodes', () => { 160 | expect(areArraysEqual([text, text], [text, identicalText])).toBe( 161 | false, 162 | ) 163 | }) 164 | test('different nodes', () => { 165 | expect(areArraysEqual([text, text], [text, differentText])).toBe( 166 | false, 167 | ) 168 | }) 169 | }) 170 | describe('node comparator', () => { 171 | test('the same nodes', () => { 172 | expect( 173 | areArraysEqual([text, text], [text, text], areNodesEqual), 174 | ).toBe(true) 175 | }) 176 | test('identical nodes', () => { 177 | expect( 178 | areArraysEqual( 179 | [text, text], 180 | [text, identicalText], 181 | areNodesEqual, 182 | ), 183 | ).toBe(true) 184 | }) 185 | test('different nodes', () => { 186 | expect( 187 | areArraysEqual( 188 | [text, text], 189 | [text, differentText], 190 | areNodesEqual, 191 | ), 192 | ).toBe(false) 193 | }) 194 | }) 195 | }) 196 | 197 | describe.each<[string, (() => string[]) | undefined]>([ 198 | ['native', window.Element.prototype.getAttributeNames], 199 | ['undefined', undefined], 200 | ])( 201 | 'areNodesEqual (getAttributeNames: %s)', 202 | (_: string, customGetAttributeNames: any) => { 203 | const originalGetAttributeNames = 204 | window.Element.prototype.getAttributeNames 205 | 206 | beforeAll(() => { 207 | window.Element.prototype.getAttributeNames = customGetAttributeNames 208 | }) 209 | 210 | afterAll(() => { 211 | window.Element.prototype.getAttributeNames = originalGetAttributeNames 212 | }) 213 | 214 | describe('shallow', () => { 215 | test('the same node', () => { 216 | expect(areNodesEqual(text, text)).toBe(true) 217 | }) 218 | test('different node types', () => { 219 | expect(areNodesEqual(text, span)).toBe(false) 220 | }) 221 | test('different node names', () => { 222 | expect(areNodesEqual(video, span)).toBe(false) 223 | }) 224 | test('different comment nodes', () => { 225 | expect(areNodesEqual(comment, differentComment)).toBe(false) 226 | }) 227 | test('identical comment nodes', () => { 228 | expect(areNodesEqual(comment, identicalComment)).toBe(true) 229 | }) 230 | test('different text nodes', () => { 231 | expect(areNodesEqual(text, differentText)).toBe(false) 232 | }) 233 | test('identical text nodes', () => { 234 | expect(areNodesEqual(text, identicalText)).toBe(true) 235 | }) 236 | test('elements with different attribute names', () => { 237 | expect(areNodesEqual(span, differentAttributeNamesSpan)).toBe( 238 | false, 239 | ) 240 | }) 241 | test('elements with different attribute values', () => { 242 | expect(areNodesEqual(span, differentAttributeValuesSpan)).toBe( 243 | false, 244 | ) 245 | }) 246 | test('elements with different childNodes', () => { 247 | expect(areNodesEqual(span, differentChildNodesSpan)).toBe(true) 248 | }) 249 | test('identical elements', () => { 250 | expect(areNodesEqual(span, identicalSpan)).toBe(true) 251 | }) 252 | test('document fragments', () => { 253 | expect(areNodesEqual(fragment, anotherFragment)).toBe(true) 254 | }) 255 | }) 256 | describe('deep', () => { 257 | const rootNode = document.createDocumentFragment() 258 | const div = document.createElement('DIV') 259 | const p = document.createElement('P') 260 | const em = document.createElement('EM') 261 | const strong = document.createElement('STRONG') 262 | rootNode.append(div, p) 263 | p.append(em, strong) 264 | em.textContent = 'em' 265 | strong.textContent = 'strong' 266 | 267 | test('identical nodes', () => { 268 | expect( 269 | areNodesEqual( 270 | rootNode.cloneNode(true), 271 | rootNode.cloneNode(true), 272 | true, 273 | ), 274 | ).toBe(true) 275 | }) 276 | test('extraneous child', () => { 277 | const differentRootNode = rootNode.cloneNode(true) 278 | ;((differentRootNode.lastChild as Node) 279 | .lastChild as Node).appendChild( 280 | document.createTextNode('different'), 281 | ) 282 | expect( 283 | areNodesEqual( 284 | rootNode.cloneNode(true), 285 | differentRootNode, 286 | true, 287 | ), 288 | ).toBe(false) 289 | }) 290 | test('child with a different attribute', () => { 291 | const differentRootNode = rootNode.cloneNode(true) 292 | ;((differentRootNode.lastChild as Node) 293 | .lastChild as Element).setAttribute('data-a', 'a') 294 | expect( 295 | areNodesEqual( 296 | rootNode.cloneNode(true), 297 | differentRootNode, 298 | true, 299 | ), 300 | ).toBe(false) 301 | }) 302 | }) 303 | }, 304 | ) 305 | 306 | describe('getAncestors', () => { 307 | const node1 = document.createDocumentFragment() 308 | const node2 = document.createElement('DIV') 309 | const node3 = document.createTextNode('test') 310 | 311 | node1.append(node2) 312 | node2.append(node3) 313 | 314 | const testData: Array<[Node, Node | undefined | null, Node[]]> = [ 315 | [node1, undefined, []], 316 | [node2, undefined, [node1]], 317 | [node3, undefined, [node2, node1]], 318 | [node1, null, []], 319 | [node2, null, [node1]], 320 | [node3, null, [node2, node1]], 321 | [node1, node1, []], 322 | [node2, node1, [node1]], 323 | [node3, node1, [node2, node1]], 324 | [node1, node2, []], 325 | [node2, node2, []], 326 | [node3, node2, [node2]], 327 | [node1, node3, []], 328 | [node2, node3, [node1]], 329 | [node3, node3, []], 330 | ] 331 | 332 | testData.forEach(([node, rootNode, ancestors]) => { 333 | test(`node: ${node.nodeName}; root: ${rootNode && 334 | rootNode.nodeName}`, () => { 335 | expect(getAncestors(node, rootNode)).toStrictEqual(ancestors) 336 | }) 337 | }) 338 | }) 339 | 340 | describe('never', () => { 341 | test('default message', () => { 342 | expect(() => never()).toThrowError( 343 | 'visual-dom-diff: Should never happen', 344 | ) 345 | }) 346 | test('custom message', () => { 347 | expect(() => never('Custom message')).toThrowError('Custom message') 348 | }) 349 | }) 350 | 351 | describe('diffText', () => { 352 | test('empty inputs', () => { 353 | expect(diffText('', '')).toStrictEqual([]) 354 | }) 355 | test('identical inputs', () => { 356 | expect(diffText('test', 'test')).toStrictEqual([[DIFF_EQUAL, 'test']]) 357 | }) 358 | test('insert into empty', () => { 359 | expect(diffText('', 'test')).toStrictEqual([[DIFF_INSERT, 'test']]) 360 | }) 361 | test('delete all', () => { 362 | expect(diffText('test', '')).toStrictEqual([[DIFF_DELETE, 'test']]) 363 | }) 364 | test('different letter case', () => { 365 | expect(diffText('test', 'Test')).toStrictEqual([ 366 | [DIFF_DELETE, 't'], 367 | [DIFF_INSERT, 'T'], 368 | [DIFF_EQUAL, 'est'], 369 | ]) 370 | }) 371 | test('different whitespace', () => { 372 | expect(diffText('start end', 'start end')).toStrictEqual([ 373 | [DIFF_EQUAL, 'start '], 374 | [DIFF_INSERT, ' '], 375 | [DIFF_EQUAL, 'end'], 376 | ]) 377 | }) 378 | test('word added', () => { 379 | expect(diffText('start end', 'start add end')).toStrictEqual([ 380 | [DIFF_EQUAL, 'start '], 381 | [DIFF_INSERT, 'add '], 382 | [DIFF_EQUAL, 'end'], 383 | ]) 384 | }) 385 | test('word removed', () => { 386 | expect(diffText('start remove end', 'start end')).toStrictEqual([ 387 | [DIFF_EQUAL, 'start '], 388 | [DIFF_DELETE, 'remove '], 389 | [DIFF_EQUAL, 'end'], 390 | ]) 391 | }) 392 | test('word replaced', () => { 393 | expect(diffText('start remove end', 'start add end')).toStrictEqual([ 394 | [DIFF_EQUAL, 'start '], 395 | [DIFF_DELETE, 'remove'], 396 | [DIFF_INSERT, 'add'], 397 | [DIFF_EQUAL, ' end'], 398 | ]) 399 | }) 400 | test('word added with a node marker', () => { 401 | expect( 402 | diffText( 403 | `${pChar}start${pChar}end`, 404 | `${pChar}start${pChar}add${pChar}end`, 405 | ), 406 | ).toStrictEqual([ 407 | [DIFF_EQUAL, `${pChar}start`], 408 | [DIFF_INSERT, `${pChar}add`], 409 | [DIFF_EQUAL, `${pChar}end`], 410 | ]) 411 | }) 412 | test('word removed with a node marker', () => { 413 | expect( 414 | diffText( 415 | `${pChar}start${pChar}remove${pChar}end`, 416 | `${pChar}start${pChar}end`, 417 | ), 418 | ).toStrictEqual([ 419 | [DIFF_EQUAL, `${pChar}start`], 420 | [DIFF_DELETE, `${pChar}remove`], 421 | [DIFF_EQUAL, `${pChar}end`], 422 | ]) 423 | }) 424 | test('word replaced in text with node markers', () => { 425 | expect( 426 | diffText( 427 | `${pChar}start${pChar}remove${pChar}end`, 428 | `${pChar}start${pChar}add${pChar}end`, 429 | ), 430 | ).toStrictEqual([ 431 | [DIFF_EQUAL, `${pChar}start${pChar}`], 432 | [DIFF_DELETE, 'remove'], 433 | [DIFF_INSERT, 'add'], 434 | [DIFF_EQUAL, `${pChar}end`], 435 | ]) 436 | }) 437 | test('semantic diff', () => { 438 | expect(diffText('mouse', 'sofas')).toStrictEqual([ 439 | [DIFF_DELETE, 'mouse'], 440 | [DIFF_INSERT, 'sofas'], 441 | ]) 442 | }) 443 | describe('skip node markers when running diff_cleanupSemantic', () => { 444 | test('equal node markers only', () => { 445 | expect( 446 | diffText( 447 | '\uE000\uE001\uE002\uE003', 448 | '\uE000\uE001\uE002\uE003', 449 | ), 450 | ).toStrictEqual([[DIFF_EQUAL, '\uE000\uE001\uE002\uE003']]) 451 | }) 452 | test('equal node markers with prefix', () => { 453 | expect( 454 | diffText( 455 | 'a\uE000\uE001\uE002\uE003', 456 | 'a\uE000\uE001\uE002\uE003', 457 | ), 458 | ).toStrictEqual([[DIFF_EQUAL, 'a\uE000\uE001\uE002\uE003']]) 459 | }) 460 | test('equal node markers with suffix', () => { 461 | expect( 462 | diffText( 463 | '\uE000\uE001\uE002\uE003z', 464 | '\uE000\uE001\uE002\uE003z', 465 | ), 466 | ).toStrictEqual([[DIFF_EQUAL, '\uE000\uE001\uE002\uE003z']]) 467 | }) 468 | test('equal node markers with prefix and suffix', () => { 469 | expect( 470 | diffText( 471 | 'a\uE000\uE001\uE002\uE003z', 472 | 'a\uE000\uE001\uE002\uE003z', 473 | ), 474 | ).toStrictEqual([[DIFF_EQUAL, 'a\uE000\uE001\uE002\uE003z']]) 475 | }) 476 | test('equal prefix only', () => { 477 | expect(diffText('prefix', 'prefix')).toStrictEqual([ 478 | [DIFF_EQUAL, 'prefix'], 479 | ]) 480 | }) 481 | test('changed letter within text', () => { 482 | expect(diffText('prefixAsuffix', 'prefixBsuffix')).toStrictEqual([ 483 | [DIFF_EQUAL, 'prefix'], 484 | [DIFF_DELETE, 'A'], 485 | [DIFF_INSERT, 'B'], 486 | [DIFF_EQUAL, 'suffix'], 487 | ]) 488 | }) 489 | test('changed node within text', () => { 490 | expect( 491 | diffText('prefix\uE000suffix', 'prefix\uE001suffix'), 492 | ).toStrictEqual([ 493 | [DIFF_EQUAL, 'prefix'], 494 | [DIFF_DELETE, '\uE000'], 495 | [DIFF_INSERT, '\uE001'], 496 | [DIFF_EQUAL, 'suffix'], 497 | ]) 498 | }) 499 | test('multiple changed letters around equal letter', () => { 500 | expect(diffText('abc!def', '123!456')).toStrictEqual([ 501 | [DIFF_DELETE, 'abc!def'], 502 | [DIFF_INSERT, '123!456'], 503 | ]) 504 | }) 505 | test('multiple changed node markers around equal letter', () => { 506 | expect( 507 | diffText( 508 | '\uE000\uE001\uE002!\uE003\uE004\uE005', 509 | '\uE006\uE007\uE008!\uE009\uE00A\uE00B', 510 | ), 511 | ).toStrictEqual([ 512 | [DIFF_DELETE, '\uE000\uE001\uE002!\uE003\uE004\uE005'], 513 | [DIFF_INSERT, '\uE006\uE007\uE008!\uE009\uE00A\uE00B'], 514 | ]) 515 | }) 516 | test('multiple changed letters around equal node marker', () => { 517 | expect(diffText('abc\uE000def', '123\uE000456')).toStrictEqual([ 518 | [DIFF_DELETE, 'abc'], 519 | [DIFF_INSERT, '123'], 520 | [DIFF_EQUAL, '\uE000'], 521 | [DIFF_DELETE, 'def'], 522 | [DIFF_INSERT, '456'], 523 | ]) 524 | }) 525 | test('multiple changed node markers around equal node marker', () => { 526 | expect( 527 | diffText( 528 | '\uE000\uE001\uE002\uF000\uE003\uE004\uE005', 529 | '\uE006\uE007\uE008\uF000\uE009\uE00A\uE00B', 530 | ), 531 | ).toStrictEqual([ 532 | [DIFF_DELETE, '\uE000\uE001\uE002'], 533 | [DIFF_INSERT, '\uE006\uE007\uE008'], 534 | [DIFF_EQUAL, '\uF000'], 535 | [DIFF_DELETE, '\uE003\uE004\uE005'], 536 | [DIFF_INSERT, '\uE009\uE00A\uE00B'], 537 | ]) 538 | }) 539 | test.each([ 540 | '!', 541 | '\u0000', 542 | '\uDFFF', 543 | '\uF900', 544 | '\uFFFF', 545 | '\uDFFF\uF900', 546 | ])( 547 | 'identical text without node markers inside changed text (%#)', 548 | string => { 549 | expect( 550 | diffText(`abcdef${string}ghijkl`, `123456${string}7890-=`), 551 | ).toStrictEqual([ 552 | [DIFF_DELETE, `abcdef${string}ghijkl`], 553 | [DIFF_INSERT, `123456${string}7890-=`], 554 | ]) 555 | }, 556 | ) 557 | test.each([ 558 | '\uE000', 559 | '\uEFEF', 560 | '\uF8FF', 561 | '\uE000\uF8FF', 562 | '\uE000!\uF8FF', 563 | ])( 564 | 'identical text with node markers inside changed text (%#)', 565 | string => { 566 | expect( 567 | diffText(`abcdef${string}ghijkl`, `123456${string}7890-=`), 568 | ).toStrictEqual([ 569 | [DIFF_DELETE, 'abcdef'], 570 | [DIFF_INSERT, '123456'], 571 | [DIFF_EQUAL, string], 572 | [DIFF_DELETE, 'ghijkl'], 573 | [DIFF_INSERT, '7890-='], 574 | ]) 575 | }, 576 | ) 577 | }) 578 | }) 579 | 580 | describe('cleanUpNodeMarkers', () => { 581 | test('cleans up multiple node markers in delete', () => { 582 | const diff: Diff[] = [ 583 | [DIFF_EQUAL, `abc${pChar}${ulChar}${liChar}${liChar}`], 584 | [DIFF_DELETE, `${pChar}${ulChar}${liChar}${liChar}`], 585 | [DIFF_EQUAL, `${pChar}${ulChar}${liChar}${liChar}xyz`], 586 | ] 587 | cleanUpNodeMarkers(diff) 588 | expect(diff).toStrictEqual([ 589 | [DIFF_EQUAL, `abc`], 590 | [DIFF_DELETE, `${pChar}${ulChar}${liChar}${liChar}`], 591 | [ 592 | DIFF_EQUAL, 593 | `${pChar}${ulChar}${liChar}${liChar}${pChar}${ulChar}${liChar}${liChar}xyz`, 594 | ], 595 | ]) 596 | }) 597 | test('cleans up multiple node markers in insert', () => { 598 | const diff: Diff[] = [ 599 | [DIFF_EQUAL, `abc${pChar}${ulChar}${liChar}${liChar}`], 600 | [DIFF_INSERT, `${pChar}${ulChar}${liChar}${liChar}`], 601 | [DIFF_EQUAL, `${pChar}${ulChar}${liChar}${liChar}xyz`], 602 | ] 603 | cleanUpNodeMarkers(diff) 604 | expect(diff).toStrictEqual([ 605 | [DIFF_EQUAL, `abc`], 606 | [DIFF_INSERT, `${pChar}${ulChar}${liChar}${liChar}`], 607 | [ 608 | DIFF_EQUAL, 609 | `${pChar}${ulChar}${liChar}${liChar}${pChar}${ulChar}${liChar}${liChar}xyz`, 610 | ], 611 | ]) 612 | }) 613 | test('cleans up a node marker in delete and removes a redundant diff item', () => { 614 | const diff: Diff[] = [ 615 | [DIFF_EQUAL, `${pChar}`], 616 | [DIFF_DELETE, `abc${pChar}`], 617 | [DIFF_EQUAL, `${pChar}xyz`], 618 | ] 619 | cleanUpNodeMarkers(diff) 620 | expect(diff).toStrictEqual([ 621 | [DIFF_DELETE, `${pChar}abc`], 622 | [DIFF_EQUAL, `${pChar}${pChar}xyz`], 623 | ]) 624 | }) 625 | test('cleans up a node marker in insert and removes a redundant diff item', () => { 626 | const diff: Diff[] = [ 627 | [DIFF_EQUAL, `${pChar}`], 628 | [DIFF_INSERT, `abc${pChar}`], 629 | [DIFF_EQUAL, `${pChar}xyz`], 630 | ] 631 | cleanUpNodeMarkers(diff) 632 | expect(diff).toStrictEqual([ 633 | [DIFF_INSERT, `${pChar}abc`], 634 | [DIFF_EQUAL, `${pChar}${pChar}xyz`], 635 | ]) 636 | }) 637 | }) 638 | -------------------------------------------------------------------------------- /src/diff.test.ts: -------------------------------------------------------------------------------- 1 | import { Diff, DIFF_DELETE, DIFF_INSERT } from 'diff-match-patch' 2 | import { JSDOM } from 'jsdom' 3 | import { Options } from './config' 4 | import { visualDomDiff } from './diff' 5 | import { areNodesEqual, charForNodeName, isElement, isText } from './util' 6 | 7 | jest.setTimeout(2000) 8 | 9 | const document = new JSDOM('').window.document 10 | const pChar = charForNodeName('P') 11 | 12 | function fragmentToHtml(documentFragment: DocumentFragment): string { 13 | return Array.from(documentFragment.childNodes).reduce( 14 | (html, node) => 15 | html + 16 | (isText(node) ? node.data : isElement(node) ? node.outerHTML : ''), 17 | '', 18 | ) 19 | } 20 | 21 | function htmlToFragment(html: string): DocumentFragment { 22 | const template = document.createElement('template') 23 | template.innerHTML = html 24 | return template.content 25 | } 26 | 27 | function trimLines(text: string): string { 28 | return text.replace(/(^|\n)\s*/g, '') 29 | } 30 | 31 | test.each<[string, Node, Node, string, Options | undefined]>([ 32 | [ 33 | 'empty documents', 34 | new JSDOM('').window.document, 35 | new JSDOM('').window.document, 36 | '', 37 | undefined, 38 | ], 39 | [ 40 | 'documents with identical content', 41 | new JSDOM('Hello').window.document, 42 | new JSDOM('Hello').window.document, 43 | 'Hello', 44 | undefined, 45 | ], 46 | [ 47 | 'documents with different content', 48 | new JSDOM('Prefix Old Suffix').window.document, 49 | new JSDOM('Prefix New Suffix').window.document, 50 | 'Prefix OldNew Suffix', 51 | undefined, 52 | ], 53 | [ 54 | 'empty document fragments', 55 | document.createDocumentFragment(), 56 | document.createDocumentFragment(), 57 | '', 58 | undefined, 59 | ], 60 | [ 61 | 'empty identical DIVs', 62 | document.createElement('DIV'), 63 | document.createElement('DIV'), 64 | '
', 65 | undefined, 66 | ], 67 | [ 68 | 'empty text nodes', 69 | document.createTextNode(''), 70 | document.createTextNode(''), 71 | '', 72 | undefined, 73 | ], 74 | [ 75 | 'identical text nodes', 76 | document.createTextNode('test'), 77 | document.createTextNode('test'), 78 | 'test', 79 | undefined, 80 | ], 81 | [ 82 | 'different text nodes', 83 | document.createTextNode('Prefix Old Suffix'), 84 | document.createTextNode('Prefix New Suffix'), 85 | 'Prefix OldNew Suffix', 86 | undefined, 87 | ], 88 | [ 89 | 'identical text in a DIV', 90 | (() => { 91 | const div = document.createElement('DIV') 92 | div.textContent = 'test' 93 | return div 94 | })(), 95 | (() => { 96 | const div = document.createElement('DIV') 97 | div.textContent = 'test' 98 | return div 99 | })(), 100 | '
test
', 101 | undefined, 102 | ], 103 | [ 104 | 'identical text in a DIV in a document fragment', 105 | htmlToFragment('
test
'), 106 | htmlToFragment('
test
'), 107 | '
test
', 108 | undefined, 109 | ], 110 | [ 111 | 'identical text in different text nodes', 112 | (() => { 113 | const fragment = document.createDocumentFragment() 114 | fragment.append( 115 | document.createTextNode('He'), 116 | document.createTextNode(''), 117 | document.createTextNode('llo'), 118 | document.createTextNode(' World'), 119 | ) 120 | return fragment 121 | })(), 122 | (() => { 123 | const fragment = document.createDocumentFragment() 124 | fragment.append( 125 | document.createTextNode('H'), 126 | document.createTextNode('ello W'), 127 | document.createTextNode('or'), 128 | document.createTextNode(''), 129 | document.createTextNode('ld'), 130 | ) 131 | return fragment 132 | })(), 133 | 'Hello World', 134 | undefined, 135 | ], 136 | [ 137 | 'identical images', 138 | document.createElement('IMG'), 139 | document.createElement('IMG'), 140 | '', 141 | undefined, 142 | ], 143 | [ 144 | 'different images', 145 | (() => { 146 | const img = document.createElement('IMG') 147 | img.setAttribute('src', 'image.png') 148 | return img 149 | })(), 150 | (() => { 151 | const img = document.createElement('IMG') 152 | img.setAttribute('src', 'image.jpg') 153 | return img 154 | })(), 155 | '', 156 | undefined, 157 | ], 158 | [ 159 | 'complex identical content', 160 | htmlToFragment( 161 | trimLines(` 162 |
163 |

Paragraph 1

164 |

Paragraph 2

165 | 166 | 167 | More text 168 | 169 |
170 |
171 | `), 172 | ), 173 | htmlToFragment( 174 | trimLines(` 175 |
176 |

Paragraph 1

177 |

Paragraph 2

178 | 179 | 180 | More text 181 | 182 |
183 |
184 | `), 185 | ), 186 | trimLines(` 187 |
188 |

Paragraph 1

189 |

Paragraph 2

190 | 191 | 192 | More text 193 | 194 |
195 |
196 | `), 197 | undefined, 198 | ], 199 | [ 200 | 'same structure but different nodes', 201 | htmlToFragment('Prefix
  • Test
Suffix'), 202 | htmlToFragment('Prefix
  1. Test
Suffix'), 203 | 'Prefix
  • Test
  1. Test
Suffix', 204 | undefined, 205 | ], 206 | [ 207 | 'a character replaces a paragraph', 208 | (() => { 209 | const p = document.createElement('P') 210 | p.textContent = 'Test' 211 | return p 212 | })(), 213 | document.createTextNode(`${pChar}Test`), 214 | `

Test

${pChar}Test`, 215 | undefined, 216 | ], 217 | [ 218 | 'a paragraph replaces a character', 219 | document.createTextNode(`${pChar}Test`), 220 | (() => { 221 | const p = document.createElement('P') 222 | p.textContent = 'Test' 223 | return p 224 | })(), 225 | `${pChar}Test

Test

`, 226 | undefined, 227 | ], 228 | [ 229 | 'some text removed', 230 | htmlToFragment('Prefix Removed Suffix'), 231 | htmlToFragment('Prefix Suffix'), 232 | 'Prefix Removed Suffix', 233 | undefined, 234 | ], 235 | [ 236 | 'some text added', 237 | htmlToFragment('Prefix Suffix'), 238 | htmlToFragment('Prefix Added Suffix'), 239 | 'Prefix Added Suffix', 240 | undefined, 241 | ], 242 | [ 243 | 'some text and a paragraph removed', 244 | htmlToFragment('Prefix Removed

Paragraph

Suffix'), 245 | htmlToFragment('Prefix Suffix'), 246 | 'Prefix Removed

Paragraph

Suffix', 247 | undefined, 248 | ], 249 | [ 250 | 'some text and a paragraph added', 251 | htmlToFragment('Prefix Suffix'), 252 | htmlToFragment('Prefix Added

Paragraph

Suffix'), 253 | 'Prefix Added

Paragraph

Suffix', 254 | undefined, 255 | ], 256 | [ 257 | 'some content unwrapped', 258 | htmlToFragment('Prefix

Paragraph

Suffix'), 259 | htmlToFragment('Prefix Paragraph Suffix'), 260 | 'Prefix

Paragraph

Paragraph Suffix', 261 | undefined, 262 | ], 263 | [ 264 | 'some content wrapped', 265 | htmlToFragment('Prefix Paragraph Suffix'), 266 | htmlToFragment('Prefix

Paragraph

Suffix'), 267 | 'Prefix Paragraph

Paragraph

Suffix', 268 | undefined, 269 | ], 270 | [ 271 | 'formatting removed', 272 | htmlToFragment('Prefix StrongEm Suffix'), 273 | htmlToFragment('Prefix StrongEm Suffix'), 274 | 'Prefix StrongEm Suffix', 275 | undefined, 276 | ], 277 | [ 278 | 'formatting added', 279 | htmlToFragment('Prefix StrongEm Suffix'), 280 | htmlToFragment('Prefix StrongEm Suffix'), 281 | 'Prefix StrongEm Suffix', 282 | undefined, 283 | ], 284 | [ 285 | 'formatting modified', 286 | htmlToFragment('Prefix formatted Suffix'), 287 | htmlToFragment('Prefix formatted Suffix'), 288 | 'Prefix formatted Suffix', 289 | undefined, 290 | ], 291 | [ 292 | 'nested formatting', 293 | htmlToFragment('Prefix formatted Suffix'), 294 | htmlToFragment('Prefix formatted Suffix'), 295 | 'Prefix formatted Suffix', 296 | undefined, 297 | ], 298 | [ 299 | '2 text nodes with modified formatting', 300 | htmlToFragment('Prefix formatted Suffix'), 301 | htmlToFragment( 302 | 'Prefix formatted Suffix', 303 | ), 304 | 'Prefix formatted Suffix', 305 | undefined, 306 | ], 307 | [ 308 | 'nested text change', 309 | htmlToFragment( 310 | trimLines(` 311 |
312 |

313 | Prefix before Suffix 314 |

315 |
316 | `), 317 | ), 318 | htmlToFragment( 319 | trimLines(` 320 |
321 |

322 | Prefix after Suffix 323 |

324 |
325 | `), 326 | ), 327 | trimLines(` 328 |
329 |

330 | Prefix 331 | before 332 | after 333 | Suffix 334 |

335 |
336 | `), 337 | undefined, 338 | ], 339 | [ 340 | 'formatting in differing content - the same text diff', 341 | htmlToFragment('
  • text
'), 342 | htmlToFragment('
  1. text
'), 343 | '
  • text
' + 344 | '
  1. text
', 345 | undefined, 346 | ], 347 | [ 348 | 'formatting in differing content - different text diff', 349 | htmlToFragment('
  • before
'), 350 | htmlToFragment('
  1. after
'), 351 | '
  • before
' + 352 | '
  1. after
', 353 | undefined, 354 | ], 355 | [ 356 | 'differing image src', 357 | htmlToFragment('
'), 358 | htmlToFragment('
'), 359 | '
', 360 | undefined, 361 | ], 362 | [ 363 | 'differing paragraph attribute - the same text diff', 364 | htmlToFragment('

test

'), 365 | htmlToFragment('

test

'), 366 | '

test

', 367 | undefined, 368 | ], 369 | [ 370 | 'differing paragraph attribute - different text diff', 371 | htmlToFragment('

test

'), 372 | htmlToFragment('

hello

'), 373 | '

testhello

', 374 | undefined, 375 | ], 376 | [ 377 | 'multiple spaces between words', 378 | htmlToFragment('prefix suffix'), 379 | htmlToFragment('prefix suffix'), 380 | 'prefix suffix', 381 | undefined, 382 | ], 383 | [ 384 | 'custom diffText option', 385 | htmlToFragment('one two'), 386 | htmlToFragment('one two three'), 387 | 'one twoone two three', 388 | { 389 | diffText: (oldText: string, newText: string): Diff[] => { 390 | const diff: Diff[] = [] 391 | if (oldText) { 392 | diff.push([DIFF_DELETE, oldText]) 393 | } 394 | if (newText) { 395 | diff.push([DIFF_INSERT, newText]) 396 | } 397 | return diff 398 | }, 399 | }, 400 | ], 401 | [ 402 | 'custom skipChildren option', 403 | htmlToFragment( 404 | '

This content is skipped

Hello
', 405 | ), 406 | htmlToFragment( 407 | '

Ignored too

Hello
', 408 | ), 409 | '

Hello
', 410 | { 411 | skipChildren(node: Node): boolean | undefined { 412 | return node.nodeName === 'VIDEO' 413 | ? false 414 | : node.nodeName === 'P' 415 | ? true 416 | : undefined 417 | }, 418 | }, 419 | ], 420 | [ 421 | 'custom skipSelf option', 422 | htmlToFragment( 423 | 'p as formatting em as structure', 424 | ), 425 | htmlToFragment( 426 | '

p as formatting

em as structure', 427 | ), 428 | '

p as formatting

' + 429 | 'em as structureem as structure', 430 | { 431 | skipSelf(node: Node): boolean | undefined { 432 | return node.nodeName === 'EM' 433 | ? false 434 | : node.nodeName === 'P' 435 | ? true 436 | : undefined 437 | }, 438 | }, 439 | ], 440 | [ 441 | 'custom class names', 442 | htmlToFragment('Modified Removed'), 443 | htmlToFragment('Modified Added'), 444 | 'Modified RemovAdded', 445 | { 446 | addedClass: 'ADDED', 447 | modifiedClass: 'MODIFIED', 448 | removedClass: 'REMOVED', 449 | }, 450 | ], 451 | [ 452 | 'change letter case', 453 | htmlToFragment('Lowercase Removed'), 454 | htmlToFragment('lowercase Added'), 455 | 'Llowercase ' + 456 | 'RemovAdded', 457 | undefined, 458 | ], 459 | [ 460 | 'remove paragraph', 461 | htmlToFragment('

Removed

Common

'), 462 | htmlToFragment('

Common

'), 463 | '

Removed

Common

', 464 | undefined, 465 | ], 466 | [ 467 | 'add paragraph', 468 | htmlToFragment('

Common

'), 469 | htmlToFragment('

Added

Common

'), 470 | '

Added

Common

', 471 | undefined, 472 | ], 473 | [ 474 | 'changes with skipModified === true', 475 | htmlToFragment( 476 | '

prefix modified old suffix removed

test

', 477 | ), 478 | htmlToFragment( 479 | '

added prefix modified new suffix

test

', 480 | ), 481 | '

added prefix modified oldnew suffix removed

test

', 482 | { 483 | skipModified: true, 484 | }, 485 | ], 486 | [ 487 | 'changes with skipModified === false', 488 | htmlToFragment( 489 | '

prefix modified old suffix removed

test

', 490 | ), 491 | htmlToFragment( 492 | '

added prefix modified new suffix

test

', 493 | ), 494 | '

added prefix modified oldnew suffix removed

test

', 495 | { 496 | skipModified: false, 497 | }, 498 | ], 499 | [ 500 | 'add a paragraph between other paragraphs', 501 | htmlToFragment('

123

789

'), 502 | htmlToFragment('

123

456

789

'), 503 | '

123

456

789

', 504 | undefined, 505 | ], 506 | [ 507 | 'remove a paragraph between other paragraphs', 508 | htmlToFragment('

123

456

789

'), 509 | htmlToFragment('

123

789

'), 510 | '

123

456

789

', 511 | undefined, 512 | ], 513 | [ 514 | 'table - added', 515 | htmlToFragment('
one
'), 516 | htmlToFragment( 517 | '
one
two
', 518 | ), 519 | '
one
two
', 520 | undefined, 521 | ], 522 | [ 523 | 'table - removed', 524 | htmlToFragment( 525 | '
one
two
', 526 | ), 527 | htmlToFragment('
one
'), 528 | '
one
two
', 529 | undefined, 530 | ], 531 | [ 532 | 'table - invalid old table', 533 | htmlToFragment( 534 | '
one
', 535 | ), 536 | htmlToFragment('
one
'), 537 | '
one
one
', 538 | undefined, 539 | ], 540 | [ 541 | 'table - invalid new table', 542 | htmlToFragment('
one
'), 543 | htmlToFragment( 544 | '
one
', 545 | ), 546 | '
one
one
', 547 | undefined, 548 | ], 549 | [ 550 | // The default diff algorithm matches the caption with the cell which leads to a "broken" result. 551 | 'table - invalid diff table', 552 | htmlToFragment( 553 | '
123456
', 554 | ), 555 | htmlToFragment( 556 | '
123456
', 557 | ), 558 | '
123456
123456
', 559 | undefined, 560 | ], 561 | [ 562 | 'table - invalid - no TBODY', 563 | htmlToFragment('
'), 564 | htmlToFragment('
one
'), 565 | '
one
', 566 | undefined, 567 | ], 568 | [ 569 | 'table - invalid - empty TBODY', 570 | htmlToFragment('
'), 571 | htmlToFragment('
one
'), 572 | '
one
', 573 | undefined, 574 | ], 575 | [ 576 | 'table - invalid row in THEAD', 577 | htmlToFragment( 578 | '
', 579 | ), 580 | htmlToFragment('
one
'), 581 | '
one
', 582 | undefined, 583 | ], 584 | [ 585 | 'table - invalid row in TBODY', 586 | htmlToFragment('
'), 587 | htmlToFragment('
one
'), 588 | '
one
', 589 | undefined, 590 | ], 591 | [ 592 | 'table - invalid row in TFOOT', 593 | htmlToFragment( 594 | '
', 595 | ), 596 | htmlToFragment('
one
'), 597 | '
one
', 598 | undefined, 599 | ], 600 | [ 601 | 'table - invalid node in THEAD->TR', 602 | htmlToFragment( 603 | '
', 604 | ), 605 | htmlToFragment('
one
'), 606 | '
one
', 607 | undefined, 608 | ], 609 | [ 610 | 'table - invalid node in TBODY->TR', 611 | htmlToFragment('
'), 612 | htmlToFragment('
one
'), 613 | '
one
', 614 | undefined, 615 | ], 616 | [ 617 | 'table - invalid node in TFOOT->TR', 618 | htmlToFragment( 619 | '
', 620 | ), 621 | htmlToFragment('
one
'), 622 | '
one
', 623 | undefined, 624 | ], 625 | [ 626 | 'table - invalid - inconsistent number of cells in each row', 627 | htmlToFragment( 628 | '
', 629 | ), 630 | htmlToFragment('
one
'), 631 | '
one
', 632 | undefined, 633 | ], 634 | [ 635 | 'table - invalid rowspan', 636 | htmlToFragment( 637 | '
', 638 | ), 639 | htmlToFragment('
one
'), 640 | '
one
', 641 | undefined, 642 | ], 643 | [ 644 | 'table - invalid colspan', 645 | htmlToFragment( 646 | '
', 647 | ), 648 | htmlToFragment('
one
'), 649 | '
one
', 650 | undefined, 651 | ], 652 | [ 653 | 'table - valid but with newline breaks', 654 | htmlToFragment( 655 | '\n\n\n\n\n\n\n\n\n\n\n\n\n
onetwothree
123
', 656 | ), 657 | htmlToFragment( 658 | '\n\n\n\n\n\n\n\n\n\n\n\n\n
onetwothree
123
', 659 | ), 660 | '\n\n\n\n\n\n\n\n\n\n\n\n\n
onetwothree
123
', 661 | undefined, 662 | ], 663 | [ 664 | 'table - valid with thead, tfoot, colspan and rowspan', 665 | htmlToFragment('
one
'), 666 | htmlToFragment( 667 | '
one
', 668 | ), 669 | '
one
', 670 | undefined, 671 | ], 672 | [ 673 | 'table - add TD', 674 | htmlToFragment( 675 | '
onetwo
', 676 | ), 677 | htmlToFragment( 678 | '
onetwothree
', 679 | ), 680 | '
onetwothree
', 681 | undefined, 682 | ], 683 | [ 684 | 'table - remove TD', 685 | htmlToFragment( 686 | '
onetwothree
', 687 | ), 688 | htmlToFragment( 689 | '
onetwo
', 690 | ), 691 | '
onetwothree
', 692 | undefined, 693 | ], 694 | [ 695 | 'table - add TH', 696 | htmlToFragment( 697 | '
onetwo
', 698 | ), 699 | htmlToFragment( 700 | '
onetwothree
', 701 | ), 702 | '
onetwothree
', 703 | undefined, 704 | ], 705 | [ 706 | 'table - remove TH', 707 | htmlToFragment( 708 | '
onetwothree
', 709 | ), 710 | htmlToFragment( 711 | '
onetwo
', 712 | ), 713 | '
onetwothree
', 714 | undefined, 715 | ], 716 | [ 717 | 'table - replace TD with TH', 718 | htmlToFragment( 719 | '
onetwothree
', 720 | ), 721 | htmlToFragment( 722 | '
onetwothree
', 723 | ), 724 | '
onetwothree
', 725 | undefined, 726 | ], 727 | [ 728 | 'table - replace TH with TD', 729 | htmlToFragment( 730 | '
onetwothree
', 731 | ), 732 | htmlToFragment( 733 | '
onetwothree
', 734 | ), 735 | '
onetwothree
', 736 | undefined, 737 | ], 738 | [ 739 | 'table - move values between TDs', 740 | htmlToFragment( 741 | '
onetwo
', 742 | ), 743 | htmlToFragment( 744 | '
twoone
', 745 | ), 746 | '
onetwotwoone
', 747 | undefined, 748 | ], 749 | [ 750 | 'table - move values between THs', 751 | htmlToFragment( 752 | '
onetwo
', 753 | ), 754 | htmlToFragment( 755 | '
twoone
', 756 | ), 757 | '
onetwotwoone
', 758 | undefined, 759 | ], 760 | [ 761 | 'table - move values between a TD and TH', 762 | htmlToFragment( 763 | '
onetwo
', 764 | ), 765 | htmlToFragment( 766 | '
twoone
', 767 | ), 768 | '
onetwotwoone
', 769 | undefined, 770 | ], 771 | [ 772 | 'table - swap a TD with a TH', 773 | htmlToFragment( 774 | '
onetwo
', 775 | ), 776 | htmlToFragment( 777 | '
twoone
', 778 | ), 779 | '
onetwotwoone
', 780 | undefined, 781 | ], 782 | [ 783 | 'table - move values and add TDs', 784 | htmlToFragment( 785 | '
onetwothree
', 786 | ), 787 | htmlToFragment( 788 | '
twoonethreefourfive
', 789 | ), 790 | '
onetwotwoonethreefourfive
', 791 | undefined, 792 | ], 793 | [ 794 | 'table - move values and remove TDs', 795 | htmlToFragment( 796 | '
onetwothreefourfive
', 797 | ), 798 | htmlToFragment( 799 | '
twoonethree
', 800 | ), 801 | '
onetwotwoonethreefourfive
', 802 | undefined, 803 | ], 804 | [ 805 | 'table - move values with formatting', 806 | htmlToFragment( 807 | '
onetwothree
', 808 | ), 809 | htmlToFragment( 810 | '
twoonethree
', 811 | ), 812 | '
onetwotwoonethree
', 813 | undefined, 814 | ], 815 | [ 816 | 'table - add row to TBODY', 817 | htmlToFragment('
one
'), 818 | htmlToFragment( 819 | '
one
two
', 820 | ), 821 | '
one
two
', 822 | undefined, 823 | ], 824 | [ 825 | 'table - remove row from TBODY', 826 | htmlToFragment( 827 | '
one
two
', 828 | ), 829 | htmlToFragment('
one
'), 830 | '
one
two
', 831 | undefined, 832 | ], 833 | [ 834 | 'table - add row to THEAD', 835 | htmlToFragment( 836 | '
one
', 837 | ), 838 | htmlToFragment( 839 | '
zero
one
', 840 | ), 841 | '
zero
one
', 842 | undefined, 843 | ], 844 | [ 845 | 'table - remove row from THEAD', 846 | htmlToFragment( 847 | '
zero
one
', 848 | ), 849 | htmlToFragment( 850 | '
one
', 851 | ), 852 | '
zero
one
', 853 | undefined, 854 | ], 855 | [ 856 | 'table - add row and column', 857 | htmlToFragment( 858 | '
first columnlast column
44446666
', 859 | ), 860 | htmlToFragment( 861 | '
first columnsecond columnlast column
111122223333
444455556666
', 862 | ), 863 | '
first columnsecond columnlast column
111122223333
444455556666
', 864 | undefined, 865 | ], 866 | [ 867 | 'table - add row and remove column', 868 | htmlToFragment( 869 | '
first columnsecond columnlast column
444455556666
', 870 | ), 871 | htmlToFragment( 872 | '
first columnlast column
11113333
44446666
', 873 | ), 874 | '
first columnsecond columnlast column
11113333
444455556666
', 875 | undefined, 876 | ], 877 | [ 878 | 'table - remove row and add column', 879 | htmlToFragment( 880 | '
first columnlast column
11113333
44446666
', 881 | ), 882 | htmlToFragment( 883 | '
first columnsecond columnlast column
444455556666
', 884 | ), 885 | '
first columnsecond columnlast column
11113333
444455556666
', 886 | undefined, 887 | ], 888 | [ 889 | 'table - remove row and column', 890 | htmlToFragment( 891 | '
first columnsecond columnlast column
111122223333
444455556666
', 892 | ), 893 | htmlToFragment( 894 | '
first columnlast column
44446666
', 895 | ), 896 | '
first columnsecond columnlast column
111122223333
444455556666
', 897 | undefined, 898 | ], 899 | [ 900 | 'table - remove column and add THEAD', 901 | htmlToFragment( 902 | '
first columnsecond columnlast column
', 903 | ), 904 | htmlToFragment( 905 | '
11113333
first columnlast column
', 906 | ), 907 | '
11113333
first columnsecond columnlast column
', 908 | undefined, 909 | ], 910 | [ 911 | 'table - remove column and THEAD', 912 | htmlToFragment( 913 | '
111122223333
first columnsecond columnlast column
', 914 | ), 915 | htmlToFragment( 916 | '
first columnlast column
', 917 | ), 918 | '
111122223333
first columnsecond columnlast column
', 919 | undefined, 920 | ], 921 | ])( 922 | '%s', 923 | ( 924 | _message: string, 925 | oldNode: Node, 926 | newNode: Node, 927 | expectedHtml: string, 928 | options?: Options, 929 | ) => { 930 | const oldClone = oldNode.cloneNode(true) 931 | const newClone = newNode.cloneNode(true) 932 | const fragment = visualDomDiff(oldNode, newNode, options) 933 | expect(fragment.nodeName).toBe('#document-fragment') 934 | expect(fragmentToHtml(fragment)).toBe(expectedHtml) 935 | expect(areNodesEqual(oldNode, oldClone, true)).toBe(true) 936 | expect(areNodesEqual(newNode, newClone, true)).toBe(true) 937 | }, 938 | ) 939 | -------------------------------------------------------------------------------- /docs/b63d919808b1fcf9f335.js: -------------------------------------------------------------------------------- 1 | !function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var i in t)n.d(r,i,function(e){return t[e]}.bind(null,i));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=3)}([function(t,e){var n=function(){this.Diff_Timeout=1,this.Diff_EditCost=4,this.Match_Threshold=.5,this.Match_Distance=1e3,this.Patch_DeleteThreshold=.5,this.Patch_Margin=4,this.Match_MaxBits=32};n.Diff=function(t,e){return[t,e]},n.prototype.diff_main=function(t,e,r,i){void 0===i&&(i=this.Diff_Timeout<=0?Number.MAX_VALUE:(new Date).getTime()+1e3*this.Diff_Timeout);var o=i;if(null==t||null==e)throw new Error("Null input. (diff_main)");if(t==e)return t?[new n.Diff(0,t)]:[];void 0===r&&(r=!0);var a=r,f=this.diff_commonPrefix(t,e),s=t.substring(0,f);t=t.substring(f),e=e.substring(f),f=this.diff_commonSuffix(t,e);var l=t.substring(t.length-f);t=t.substring(0,t.length-f),e=e.substring(0,e.length-f);var u=this.diff_compute_(t,e,a,o);return s&&u.unshift(new n.Diff(0,s)),l&&u.push(new n.Diff(0,l)),this.diff_cleanupMerge(u),u},n.prototype.diff_compute_=function(t,e,r,i){var o;if(!t)return[new n.Diff(1,e)];if(!e)return[new n.Diff(-1,t)];var a=t.length>e.length?t:e,f=t.length>e.length?e:t,s=a.indexOf(f);if(-1!=s)return o=[new n.Diff(1,a.substring(0,s)),new n.Diff(0,f),new n.Diff(1,a.substring(s+f.length))],t.length>e.length&&(o[0][0]=o[2][0]=-1),o;if(1==f.length)return[new n.Diff(-1,t),new n.Diff(1,e)];var l=this.diff_halfMatch_(t,e);if(l){var u=l[0],h=l[1],d=l[2],c=l[3],g=l[4],p=this.diff_main(u,d,r,i),v=this.diff_main(h,c,r,i);return p.concat([new n.Diff(0,g)],v)}return r&&t.length>100&&e.length>100?this.diff_lineMode_(t,e,i):this.diff_bisect_(t,e,i)},n.prototype.diff_lineMode_=function(t,e,r){var i=this.diff_linesToChars_(t,e);t=i.chars1,e=i.chars2;var o=i.lineArray,a=this.diff_main(t,e,!1,r);this.diff_charsToLines_(a,o),this.diff_cleanupSemantic(a),a.push(new n.Diff(0,""));for(var f=0,s=0,l=0,u="",h="";f=1&&l>=1){a.splice(f-s-l,s+l),f=f-s-l;for(var d=this.diff_main(u,h,!1,r),c=d.length-1;c>=0;c--)a.splice(f,0,d[c]);f+=d.length}l=0,s=0,u="",h=""}f++}return a.pop(),a},n.prototype.diff_bisect_=function(t,e,r){for(var i=t.length,o=e.length,a=Math.ceil((i+o)/2),f=a,s=2*a,l=new Array(s),u=new Array(s),h=0;hr);b++){for(var _=-b+g;_<=b-p;_+=2){for(var y=f+_,x=(T=_==-b||_!=b&&l[y-1]i)p+=2;else if(x>o)g+=2;else if(c){if((M=f+d-_)>=0&&M=(N=i-u[M]))return this.diff_bisectSplit_(t,e,T,x,r)}}for(var w=-b+v;w<=b-m;w+=2){for(var N,M=f+w,D=(N=w==-b||w!=b&&u[M-1]i)m+=2;else if(D>o)v+=2;else if(!c){if((y=f+d-w)>=0&&y=(N=i-N))return this.diff_bisectSplit_(t,e,T,x,r)}}}}return[new n.Diff(-1,t),new n.Diff(1,e)]},n.prototype.diff_bisectSplit_=function(t,e,n,r,i){var o=t.substring(0,n),a=e.substring(0,r),f=t.substring(n),s=e.substring(r),l=this.diff_main(o,a,!1,i),u=this.diff_main(f,s,!1,i);return l.concat(u)},n.prototype.diff_linesToChars_=function(t,e){var n=[],r={};function i(t){for(var e="",i=0,a=-1,f=n.length;ar?t=t.substring(n-r):ne.length?t:e,r=t.length>e.length?e:t;if(n.length<4||2*r.length=t.length?[r,o,a,f,u]:null}var a,f,s,l,u,h=o(n,r,Math.ceil(n.length/4)),d=o(n,r,Math.ceil(n.length/2));return h||d?(a=d?h&&h[4].length>d[4].length?h:d:h,t.length>e.length?(f=a[0],s=a[1],l=a[2],u=a[3]):(l=a[0],u=a[1],f=a[2],s=a[3]),[f,s,l,u,a[4]]):null},n.prototype.diff_cleanupSemantic=function(t){for(var e=!1,r=[],i=0,o=null,a=0,f=0,s=0,l=0,u=0;a0?r[i-1]:-1,f=0,s=0,l=0,u=0,o=null,e=!0)),a++;for(e&&this.diff_cleanupMerge(t),this.diff_cleanupSemanticLossless(t),a=1;a=g?(c>=h.length/2||c>=d.length/2)&&(t.splice(a,0,new n.Diff(0,d.substring(0,c))),t[a-1][1]=h.substring(0,h.length-c),t[a+1][1]=d.substring(c),a++):(g>=h.length/2||g>=d.length/2)&&(t.splice(a,0,new n.Diff(0,h.substring(0,g))),t[a-1][0]=1,t[a-1][1]=d.substring(0,d.length-g),t[a+1][0]=-1,t[a+1][1]=h.substring(g),a++),a++}a++}},n.prototype.diff_cleanupSemanticLossless=function(t){function e(t,e){if(!t||!e)return 6;var r=t.charAt(t.length-1),i=e.charAt(0),o=r.match(n.nonAlphaNumericRegex_),a=i.match(n.nonAlphaNumericRegex_),f=o&&r.match(n.whitespaceRegex_),s=a&&i.match(n.whitespaceRegex_),l=f&&r.match(n.linebreakRegex_),u=s&&i.match(n.linebreakRegex_),h=l&&t.match(n.blanklineEndRegex_),d=u&&e.match(n.blanklineStartRegex_);return h||d?5:l||u?4:o&&!f&&s?3:f||s?2:o||a?1:0}for(var r=1;r=d&&(d=c,l=i,u=o,h=a)}t[r-1][1]!=l&&(l?t[r-1][1]=l:(t.splice(r-1,1),r--),t[r][1]=u,h?t[r+1][1]=h:(t.splice(r+1,1),r--))}r++}},n.nonAlphaNumericRegex_=/[^a-zA-Z0-9]/,n.whitespaceRegex_=/\s/,n.linebreakRegex_=/[\r\n]/,n.blanklineEndRegex_=/\n\r?\n$/,n.blanklineStartRegex_=/^\r?\n\r?\n/,n.prototype.diff_cleanupEfficiency=function(t){for(var e=!1,r=[],i=0,o=null,a=0,f=!1,s=!1,l=!1,u=!1;a0?r[i-1]:-1,l=u=!1),e=!0)),a++;e&&this.diff_cleanupMerge(t)},n.prototype.diff_cleanupMerge=function(t){t.push(new n.Diff(0,""));for(var e,r=0,i=0,o=0,a="",f="";r1?(0!==i&&0!==o&&(0!==(e=this.diff_commonPrefix(f,a))&&(r-i-o>0&&0==t[r-i-o-1][0]?t[r-i-o-1][1]+=f.substring(0,e):(t.splice(0,0,new n.Diff(0,f.substring(0,e))),r++),f=f.substring(e),a=a.substring(e)),0!==(e=this.diff_commonSuffix(f,a))&&(t[r][1]=f.substring(f.length-e)+t[r][1],f=f.substring(0,f.length-e),a=a.substring(0,a.length-e))),r-=i+o,t.splice(r,i+o),a.length&&(t.splice(r,0,new n.Diff(-1,a)),r++),f.length&&(t.splice(r,0,new n.Diff(1,f)),r++),r++):0!==r&&0==t[r-1][0]?(t[r-1][1]+=t[r][1],t.splice(r,1)):r++,o=0,i=0,a="",f=""}""===t[t.length-1][1]&&t.pop();var s=!1;for(r=1;re));n++)o=r,a=i;return t.length!=n&&-1===t[n][0]?a:a+(e-o)},n.prototype.diff_prettyHtml=function(t){for(var e=[],n=/&/g,r=//g,o=/\n/g,a=0;a");switch(f){case 1:e[a]=''+s+"";break;case-1:e[a]=''+s+"";break;case 0:e[a]=""+s+""}}return e.join("")},n.prototype.diff_text1=function(t){for(var e=[],n=0;nthis.Match_MaxBits)throw new Error("Pattern too long for this browser.");var r=this.match_alphabet_(e),i=this;function o(t,r){var o=t/e.length,a=Math.abs(n-r);return i.Match_Distance?o+a/i.Match_Distance:a?1:o}var a=this.Match_Threshold,f=t.indexOf(e,n);-1!=f&&(a=Math.min(o(0,f),a),-1!=(f=t.lastIndexOf(e,n+e.length))&&(a=Math.min(o(0,f),a)));var s,l,u=1<=g;m--){var b=r[t.charAt(m-1)];if(v[m]=0===c?(v[m+1]<<1|1)&b:(v[m+1]<<1|1)&b|(h[m+1]|h[m])<<1|1|h[m+1],v[m]&u){var _=o(c,m-1);if(_<=a){if(a=_,!((f=m-1)>n))break;g=Math.max(1,2*n-f)}}}if(o(c+1,n)>a)break;h=v}return f},n.prototype.match_alphabet_=function(t){for(var e={},n=0;n2&&(this.diff_cleanupSemantic(o),this.diff_cleanupEfficiency(o));else if(t&&"object"==typeof t&&void 0===e&&void 0===r)o=t,i=this.diff_text1(o);else if("string"==typeof t&&e&&"object"==typeof e&&void 0===r)i=t,o=e;else{if("string"!=typeof t||"string"!=typeof e||!r||"object"!=typeof r)throw new Error("Unknown call format to patch_make.");i=t,o=r}if(0===o.length)return[];for(var a=[],f=new n.patch_obj,s=0,l=0,u=0,h=i,d=i,c=0;c=2*this.Patch_Margin&&s&&(this.patch_addContext_(f,h),a.push(f),f=new n.patch_obj,s=0,h=d,l=u)}1!==g&&(l+=p.length),-1!==g&&(u+=p.length)}return s&&(this.patch_addContext_(f,h),a.push(f)),a},n.prototype.patch_deepCopy=function(t){for(var e=[],r=0;rthis.Match_MaxBits?-1!=(a=this.match_main(e,l.substring(0,this.Match_MaxBits),s))&&(-1==(u=this.match_main(e,l.substring(l.length-this.Match_MaxBits),s+l.length-this.Match_MaxBits))||a>=u)&&(a=-1):a=this.match_main(e,l,s),-1==a)i[o]=!1,r-=t[o].length2-t[o].length1;else if(i[o]=!0,r=a-s,l==(f=-1==u?e.substring(a,a+l.length):e.substring(a,u+this.Match_MaxBits)))e=e.substring(0,a)+this.diff_text2(t[o].diffs)+e.substring(a+l.length);else{var h=this.diff_main(l,f,!1);if(l.length>this.Match_MaxBits&&this.diff_levenshtein(h)/l.length>this.Patch_DeleteThreshold)i[o]=!1;else{this.diff_cleanupSemanticLossless(h);for(var d,c=0,g=0;ga[0][1].length){var f=e-a[0][1].length;a[0][1]=r.substring(a[0][1].length)+a[0][1],o.start1-=f,o.start2-=f,o.length1+=f,o.length2+=f}if(0==(a=(o=t[t.length-1]).diffs).length||0!=a[a.length-1][0])a.push(new n.Diff(0,r)),o.length1+=e,o.length2+=e;else if(e>a[a.length-1][1].length){f=e-a[a.length-1][1].length;a[a.length-1][1]+=r.substring(0,f),o.length1+=f,o.length2+=f}return r},n.prototype.patch_splitMax=function(t){for(var e=this.Match_MaxBits,r=0;r2*e?(s.length1+=h.length,o+=h.length,l=!1,s.diffs.push(new n.Diff(u,h)),i.diffs.shift()):(h=h.substring(0,e-s.length1-this.Patch_Margin),s.length1+=h.length,o+=h.length,0===u?(s.length2+=h.length,a+=h.length):l=!1,s.diffs.push(new n.Diff(u,h)),h==i.diffs[0][1]?i.diffs.shift():i.diffs[0][1]=i.diffs[0][1].substring(h.length))}f=(f=this.diff_text2(s.diffs)).substring(f.length-this.Patch_Margin);var d=this.diff_text1(i.diffs).substring(0,this.Patch_Margin);""!==d&&(s.length1+=d.length,s.length2+=d.length,0!==s.diffs.length&&0===s.diffs[s.diffs.length-1][0]?s.diffs[s.diffs.length-1][1]+=d:s.diffs.push(new n.Diff(0,d))),l||t.splice(++r,0,s)}}},n.prototype.patch_toText=function(t){for(var e=[],n=0;n="豈"?e++:(n[1]=a.substring(0,a.length-1),i[1]=l+f.substring(0,f.length-1),o[1]=l+s,0===n[1].length&&t.splice(e,1))}else e++}}e.isElement=i,e.isText=o,e.isDocument=function(t){return t.nodeType===t.DOCUMENT_NODE},e.isDocumentFragment=function(t){return t.nodeType===t.DOCUMENT_FRAGMENT_NODE},e.isComment=a,e.strictEqual=f,e.areArraysEqual=s,e.areNodesEqual=function t(e,n,r){if(void 0===r&&(r=!1),e===n)return!0;if(e.nodeType!==n.nodeType||e.nodeName!==n.nodeName)return!1;if(o(e)||a(e)){if(e.data!==n.data)return!1}else if(i(e)){var f=l(e).sort();if(!s(f,l(n).sort()))return!1;for(var u=0,h=f.length;u0&&o.push([r.DIFF_EQUAL,l.substring(0,h)]),c.diff_cleanupSemantic(o),g(i,o),o.length=0,i.push([r.DIFF_EQUAL,l.substring(h,u-p)]),p>0&&o.push([r.DIFF_EQUAL,l.substring(u-p)])}else o.push(s)}else o.push(s)}return c.diff_cleanupSemantic(o),g(i,o),o.length=0,c.diff_cleanupMerge(i),d(i),i},e.markUpNode=function(t,e,n){var r=t.ownerDocument,o=t.parentNode,a=t.previousSibling;if(i(t))t.classList.add(n);else if(a&&a.nodeName===e&&a.classList.contains(n))a.appendChild(t);else{var f=r.createElement(e);f.classList.add(n),o.insertBefore(f,t),f.appendChild(t)}},e.isTableValid=function(t,e){var n;return function(t){var e=u(t.childNodes),n=e.length,i=0;i=0;f--)(i=t[f])&&(a=(o<3?i(a):o>3?i(e,n,a):i(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}function s(t,e){return function(n,r){e(n,r,t)}}function l(t,e){if("object"==typeof Reflect&&"function"==typeof Reflect.metadata)return Reflect.metadata(t,e)}function u(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{s(r.next(t))}catch(t){o(t)}}function f(t){try{s(r.throw(t))}catch(t){o(t)}}function s(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,f)}s((r=r.apply(t,e||[])).next())}))}function h(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:f(0),throw:f(1),return:f(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function f(o){return function(f){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]=t.length&&(t=void 0),{value:t&&t[r++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")}function p(t,e){var n="function"==typeof Symbol&&t[Symbol.iterator];if(!n)return t;var r,i,o=n.call(t),a=[];try{for(;(void 0===e||e-- >0)&&!(r=o.next()).done;)a.push(r.value)}catch(t){i={error:t}}finally{try{r&&!r.done&&(n=o.return)&&n.call(o)}finally{if(i)throw i.error}}return a}function v(){for(var t=[],e=0;e1||f(t,e)}))})}function f(t,e){try{(n=i[t](e)).value instanceof b?Promise.resolve(n.value.v).then(s,l):u(o[0][2],n)}catch(t){u(o[0][3],t)}var n}function s(t){f("next",t)}function l(t){f("throw",t)}function u(t,e){t(e),o.shift(),o.length&&f(o[0][0],o[0][1])}}function y(t){var e,n;return e={},r("next"),r("throw",(function(t){throw t})),r("return"),e[Symbol.iterator]=function(){return this},e;function r(r,i){e[r]=t[r]?function(e){return(n=!n)?{value:b(t[r](e)),done:"return"===r}:i?i(e):e}:i}}function x(t){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var e,n=t[Symbol.asyncIterator];return n?n.call(t):(t=g(t),e={},r("next"),r("throw"),r("return"),e[Symbol.asyncIterator]=function(){return this},e);function r(n){e[n]=t[n]&&function(e){return new Promise((function(r,i){(function(t,e,n,r){Promise.resolve(r).then((function(e){t({value:e,done:n})}),e)})(r,i,(e=t[n](e)).done,e.value)}))}}}function w(t,e){return Object.defineProperty?Object.defineProperty(t,"raw",{value:e}):t.raw=e,t}function N(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var n in t)Object.hasOwnProperty.call(t,n)&&(e[n]=t[n]);return e.default=t,e}function M(t){return t&&t.__esModule?t:{default:t}}function D(t,e){if(!e.has(t))throw new TypeError("attempted to get private field on non-instance");return e.get(t)}function T(t,e,n){if(!e.has(t))throw new TypeError("attempted to set private field on non-instance");return e.set(t,n),n}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var r=n(0),i=n(6),o=n(7),a=n(1),f=function(t){return"TH"===t?"TD":t},s=function(t,e){return new o.DomIterator(t,e).reduce((function(t,e){return t+(a.isText(e)?e.data:a.charForNodeName(f(e.nodeName)))}),"")},l=function(t){return a.isText(t)?t.length:1},u=function(t){return"TR"===t.nodeName},h={skipChildren:u,skipSelf:function(t){return!u(t)}};e.visualDomDiff=function t(e,n,u){var d,c;void 0===u&&(u={});var g,p,v,m,b,_=n.ownerDocument||n,y=i.optionsToConfig(u),x=y.addedClass,w=y.diffText,N=y.modifiedClass,M=y.removedClass,D=y.skipSelf,T=y.skipChildren,E=function(t){return!D(t)},S=function(t,e){return a.getAncestors(t,e).filter(E).length},C=function(t){return a.isElement(t)&&D(t)},A=function(t,e){return a.getAncestors(t,e).filter(C).reverse()},O=function(t){return J.has(t)?1:z.has(t)?-1:0},k=w(s(e,y),s(n,y)),I=0,P=new o.DomIterator(e,y),j=new o.DomIterator(n,y),R=0,F=0,B=0;v=k[I++],d=P.next(),g=d.done,m=d.value,c=j.next(),p=c.done,b=c.value;var L=_.createDocumentFragment(),U=L,H=0,q=L,Q=0,V=null,G=null,z=new Set,J=new Set,$=new Set,K=new Map,X=new Array,Y=new Map;function Z(){for(var t=S(m,e);H>t;){if(!U.parentNode)return a.never();U===V&&(V=null),U=U.parentNode,H--}if(H!==t)return a.never()}function W(){for(var t=S(b,n);Q>t;){if(!q.parentNode)return a.never();q===G&&(G=null),q=q.parentNode,Q--}if(Q!==t)return a.never()}function tt(t){if(U!==q||G||V)return a.never();if(a.isText(t)){var r=A(m,e),i=A(b,n);K.set(t,i);var o=r.length;if(o!==i.length)$.add(t);else for(var f=0;fe)return a.never()}function it(t){var e,n=l(m);if((F+=t)===n)e=P.next(),g=e.done,m=e.value,F=0;else if(F>n)return a.never()}function ot(t){var e,n=l(b);if((B+=t)===n)e=j.next(),p=e.done,b=e.value,B=0;else if(B>n)return a.never()}for(;v;)if(v[0]===r.DIFF_DELETE){if(g)return a.never();Z();var at=Math.min(v[1].length-R,l(m)-F),ft=v[1].substring(R,R+at);et(a.isText(m)?_.createTextNode(ft):m.cloneNode(!1)),rt(at),it(at)}else if(v[0]===r.DIFF_INSERT){if(p)return a.never();W();var st=Math.min(v[1].length-R,l(b)-B);ft=v[1].substring(R,R+st);nt(a.isText(b)?_.createTextNode(ft):b.cloneNode(!1)),rt(st),ot(st)}else{if(g||p)return a.never();Z(),W();var lt=Math.min(v[1].length-R,l(m)-F,l(b)-B);ft=v[1].substring(R,R+lt);U===q&&(a.isText(m)&&a.isText(b)||f(m.nodeName)===f(b.nodeName)&&!T(m)&&!T(b)||a.areNodesEqual(m,b))?tt(a.isText(b)?_.createTextNode(ft):b.cloneNode(!1)):(et(a.isText(m)?_.createTextNode(ft):m.cloneNode(!1)),nt(a.isText(b)?_.createTextNode(ft):b.cloneNode(!1))),rt(lt),it(lt),ot(lt)}return z.forEach((function(t){for(var e=t.parentNode,n=t.previousSibling;n&&J.has(n);)e.insertBefore(t,n),n=t.previousSibling})),X.forEach((function(e){var n=e.newTable,r=e.oldTable,i=e.outputTable;if(!a.isTableValid(r,!0)||!a.isTableValid(n,!0)||!a.isTableValid(i,!1)){new o.DomIterator(i).forEach((function(t){J.delete(t),z.delete(t),$.delete(t),K.delete(t)}));var f=i.parentNode,s=r.cloneNode(!0),l=n.cloneNode(!0);return f.insertBefore(s,i),f.insertBefore(l,i),f.removeChild(i),z.add(s),void J.add(l)}var d=[];new o.DomIterator(i,h).some((function(t){var e=Y.get(t);if(!e)return!1;var n=e.oldRow,r=e.newRow,i=n.childNodes.length,o=r.childNodes.length,a=Math.max(i,o),f=Math.min(i,o);if(t.childNodes.length===a)for(var s=t.childNodes,l=0,u=s.length;l *,\n.vdd-modified > *,\n.vdd-removed > * {\n text-decoration: none;\n background-color: inherit;\n}\n\n.vdd-added {\n background: lightgreen;\n}\n.vdd-added > img {\n border: 0.125em solid lightgreen;\n}\n\n.vdd-modified {\n background: lightskyblue;\n}\n.vdd-modified > img {\n border: 0.125em solid lightskyblue;\n}\n\n.vdd-removed {\n background: lightcoral;\n}\n.vdd-removed > img {\n border: 0.125em solid lightcoral;\n}\n",""]),t.exports=e},function(t,e,n){"use strict";t.exports=function(t){var e=[];return e.toString=function(){return this.map((function(e){var n=function(t,e){var n=t[1]||"",r=t[3];if(!r)return n;if(e&&"function"==typeof btoa){var i=(a=r,f=btoa(unescape(encodeURIComponent(JSON.stringify(a)))),s="sourceMappingURL=data:application/json;charset=utf-8;base64,".concat(f),"/*# ".concat(s," */")),o=r.sources.map((function(t){return"/*# sourceURL=".concat(r.sourceRoot||"").concat(t," */")}));return[n].concat(o).concat([i]).join("\n")}var a,f,s;return[n].join("\n")}(e,t);return e[2]?"@media ".concat(e[2]," {").concat(n,"}"):n})).join("")},e.i=function(t,n,r){"string"==typeof t&&(t=[[null,t,""]]);var i={};if(r)for(var o=0;o