├── .all-contributorsrc ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .idea ├── modules.xml └── react-diff-viewer-continued.iml ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── biome.json ├── example.jpg ├── examples ├── index.html ├── src │ ├── diff │ │ ├── javascript │ │ │ ├── new.rjs │ │ │ └── old.rjs │ │ ├── json │ │ │ ├── new.json │ │ │ └── old.json │ │ ├── massive │ │ │ ├── new.yaml │ │ │ └── old.yaml │ │ └── xml │ │ │ ├── new.xml │ │ │ └── old.xml │ ├── index.tsx │ ├── style.scss │ └── types.d.ts └── vite.config.ts ├── logo-standalone.png ├── logo.png ├── logo.png~ ├── logo_dark.png ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── publish-examples.sh ├── release.config.js ├── src ├── compute-hidden-blocks.ts ├── compute-lines.ts ├── expand.tsx ├── fold.tsx ├── global.d.ts ├── index.tsx └── styles.ts ├── test ├── compute-lines.test.ts └── react-diff-viewer.test.tsx ├── tsconfig.esm.json ├── tsconfig.json └── vitest.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "ericmorgan1", 11 | "name": "Eric M.", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/10346191?v=4", 13 | "profile": "https://github.com/ericmorgan1", 14 | "contributions": [ 15 | "code" 16 | ] 17 | }, 18 | { 19 | "login": "spyroid", 20 | "name": "Andrei Kovalevsky", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/844495?v=4", 22 | "profile": "https://github.com/spyroid", 23 | "contributions": [ 24 | "code" 25 | ] 26 | }, 27 | { 28 | "login": "KimBiYam", 29 | "name": "Chang Hyun Kim", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/59679962?v=4", 31 | "profile": "http://kimbiyam.me", 32 | "contributions": [ 33 | "code" 34 | ] 35 | } 36 | ], 37 | "contributorsPerLine": 7, 38 | "skipCi": true, 39 | "repoType": "github", 40 | "repoHost": "https://github.com", 41 | "projectName": "react-diff-viewer-continued", 42 | "projectOwner": "Aeolun" 43 | } 44 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/.github/FUNDING.yml -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | on: 3 | push: 4 | 5 | jobs: 6 | test: 7 | name: Unit tests 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 'lts/*' 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: latest 21 | - name: Install dependencies 22 | run: pnpm i 23 | - name: Run unit tests 24 | run: pnpm run test 25 | release: 26 | name: Release 27 | needs: test 28 | if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/next' 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | - name: Setup Node.js 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: 'lts/*' 39 | - uses: pnpm/action-setup@v4 40 | with: 41 | version: latest 42 | - name: Install dependencies 43 | run: pnpm i 44 | - name: Build application 45 | run: pnpm run build 46 | - name: Release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | run: pnpm semantic-release 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | *.log 5 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/react-diff-viewer-continued.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | test 3 | *.svg 4 | /src 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [4.0.6](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.5...v4.0.6) (2025-05-13) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * allow non-string rendered contents ([a0ab52d](https://github.com/aeolun/react-diff-viewer-continued/commit/a0ab52d3d064297d1957c7fb65f9742f53a191a7)) 7 | * fixup [#68](https://github.com/aeolun/react-diff-viewer-continued/issues/68) for non-string wordDiff.value ([3678e02](https://github.com/aeolun/react-diff-viewer-continued/commit/3678e020855f0fec8f15084291cb5adc54a2c009)) 8 | 9 | ## [4.0.5](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.4...v4.0.5) (2025-01-31) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * modify imports for proper esm resolution ([7fda63a](https://github.com/aeolun/react-diff-viewer-continued/commit/7fda63afae8bcd98547c8bdff569d02256821b2d)) 15 | 16 | ## [4.0.4](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.3...v4.0.4) (2025-01-28) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * added line number for inline view onLineNumberClick ([0e92dfe](https://github.com/aeolun/react-diff-viewer-continued/commit/0e92dfee2102b42bdd0c51af57c66b0152ad2186)) 22 | * fix several type issues and update packages ([23aa832](https://github.com/aeolun/react-diff-viewer-continued/commit/23aa83222e85d303b939eb20699348e449a9174f)) 23 | * line break anywhere ([17c51e6](https://github.com/aeolun/react-diff-viewer-continued/commit/17c51e62afd6ffcacee2fe731f1ff0ee44c08e37)) 24 | 25 | ## [4.0.3](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.2...v4.0.3) (2024-05-23) 26 | 27 | 28 | ### Reverts 29 | 30 | * Revert "refactoring attempt" ([6a9789b](https://github.com/aeolun/react-diff-viewer-continued/commit/6a9789b0af0221bf32be11d1af9d4db3337008f4)) 31 | 32 | ## [4.0.2](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.1...v4.0.2) (2024-02-14) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * revert back to table based layout, add example image (fixes [#35](https://github.com/aeolun/react-diff-viewer-continued/issues/35), [#15](https://github.com/aeolun/react-diff-viewer-continued/issues/15), [#21](https://github.com/aeolun/react-diff-viewer-continued/issues/21)) ([a1571ab](https://github.com/aeolun/react-diff-viewer-continued/commit/a1571ab9940c8b917c2e845f537780e4b45efb01)) 38 | 39 | ## [4.0.1](https://github.com/aeolun/react-diff-viewer-continued/compare/v4.0.0...v4.0.1) (2023-11-01) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * publish files on 4.x ([650c249](https://github.com/aeolun/react-diff-viewer-continued/commit/650c249c5bf1d8b27d780b65555df5ae0f5d9e2b)) 45 | 46 | # [4.0.0](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.3.0...v4.0.0) (2023-10-19) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * do not trim trailing newlines (fixes [#27](https://github.com/aeolun/react-diff-viewer-continued/issues/27)) ([ee4d53f](https://github.com/aeolun/react-diff-viewer-continued/commit/ee4d53f8e2ba3e374b51bffef3a00d3fe6206d02)) 52 | * use semantic elements for diff elements (fixes [#23](https://github.com/aeolun/react-diff-viewer-continued/issues/23)) ([a6222c7](https://github.com/aeolun/react-diff-viewer-continued/commit/a6222c7af151e7dc29046c8eac916271866b4899)) 53 | 54 | 55 | * feat!: diff/flexbox based layout, text selectable per side (fixes #15) ([9f6c4d5](https://github.com/aeolun/react-diff-viewer-continued/commit/9f6c4d59e84ecb44761c39e172ffab6a689d5779)), closes [#15](https://github.com/aeolun/react-diff-viewer-continued/issues/15) 56 | 57 | 58 | ### Features 59 | 60 | * add summary element and fold expansion/folding (fixes [#22](https://github.com/aeolun/react-diff-viewer-continued/issues/22), [#21](https://github.com/aeolun/react-diff-viewer-continued/issues/21)) ([e47cbe1](https://github.com/aeolun/react-diff-viewer-continued/commit/e47cbe1286a2143b0f8078a683e96edea0ed967b)) 61 | 62 | 63 | ### BREAKING CHANGES 64 | 65 | * This completely modifies the way react-diff-viewer-continued is rendered. The overall 66 | layout should be more or less the same, but with the new layout probably come new bugs. 67 | 68 | # [3.3.0](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.6...v3.3.0) (2023-10-17) 69 | 70 | 71 | ### Bug Fixes 72 | 73 | * update dependencies and correct zero width extraLines (fixes [#29](https://github.com/aeolun/react-diff-viewer-continued/issues/29)) ([c4b317a](https://github.com/aeolun/react-diff-viewer-continued/commit/c4b317af31935740dd9fe8ac526ceb8fd63db6a9)) 74 | 75 | 76 | ### Features 77 | 78 | * add ability to always show certain lines ([896eb32](https://github.com/aeolun/react-diff-viewer-continued/commit/896eb323389cec2055abc7dede40adcbcbf6b506)) 79 | 80 | ## [3.2.6](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.5...v3.2.6) (2023-03-02) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * release for chore fix ([9775afa](https://github.com/aeolun/react-diff-viewer-continued/commit/9775afac2388942d97c839954186eb5b4fd64c3c)) 86 | 87 | ## [3.2.5](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.4...v3.2.5) (2023-01-23) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * correctly break strings for long values so size remains within bounds ([cfa5de1](https://github.com/aeolun/react-diff-viewer-continued/commit/cfa5de1905644c34152dc8a692191d1e32410353)) 93 | 94 | ## [3.2.4](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.3...v3.2.4) (2022-12-23) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * to deploy previous fixes ([06d8361](https://github.com/aeolun/react-diff-viewer-continued/commit/06d83614204d0c48c3ac654b06c43ba42f679c56)) 100 | 101 | ## [3.2.3](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.2...v3.2.3) (2022-11-11) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * update example with JSON ([f61c977](https://github.com/aeolun/react-diff-viewer-continued/commit/f61c977302415774dd32d48aca3addb7122ffa55)) 107 | 108 | ## [3.2.2](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.1...v3.2.2) (2022-10-10) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * move the dependencies for development only to `devDependencies` ([206394c](https://github.com/aeolun/react-diff-viewer-continued/commit/206394cb16352f2c3601383b8510b4dee9578405)) 114 | 115 | ## [3.2.1](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.2.0...v3.2.1) (2022-07-07) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * correct diff line numbering ([bab9977](https://github.com/aeolun/react-diff-viewer-continued/commit/bab99777fd687f85be68fb5c2990e554b6cb70bf)) 121 | 122 | # [3.2.0](https://github.com/aeolun/react-diff-viewer-continued/compare/v3.1.1...v3.2.0) (2022-07-07) 123 | 124 | ### Features 125 | 126 | - update library for react 17, and add custom gutters ([7193350](https://github.com/aeolun/react-diff-viewer-continued/commit/7193350187ed5b13989e6d5e5ade40f3a45c943b)) 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bart Riepe 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Diff Viewer 2 | 3 |
4 | 5 | 6 | [![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-) 7 | 8 | 9 | [![npm version](https://img.shields.io/npm/v/react-diff-viewer-continued)](https://www.npmjs.com/package/react-diff-viewer-continued) 10 | [![npm downloads](https://img.shields.io/npm/dw/react-diff-viewer-continued)](https://www.npmjs.com/package/react-diff-viewer-continued) 11 | [![GitHub license](https://img.shields.io/github/license/aeolun/react-diff-viewer-continued.svg)](https://github.com/aeolun/react-diff-viewer-continued/blob/master/LICENSE) 12 | 13 | A simple and beautiful text diff viewer component made with [Diff](https://github.com/kpdecker/jsdiff) and [React](https://reactjs.org). 14 | 15 | ![example image](./example.jpg) 16 | 17 | Inspired by the Github diff viewer, it includes features like split view, inline view, word diff, line highlight and more. It is highly customizable and it supports almost all languages. 18 | 19 | Most credit goes to [Pranesh Ravi](https://praneshravi.in) who created the [original diff viewer](https://github.com/praneshr/react-diff-viewer). I've just made a few modifications and updated the dependencies so they work with modern stacks. 20 | 21 | ## Install 22 | 23 | ```bash 24 | yarn add react-diff-viewer-continued 25 | 26 | # or 27 | 28 | npm i react-diff-viewer-continued 29 | 30 | # or 31 | 32 | pnpm add react-diff-viewer-continued 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```javascript 38 | import React, { PureComponent } from 'react'; 39 | import ReactDiffViewer from 'react-diff-viewer-continued'; 40 | 41 | const oldCode = ` 42 | const a = 10 43 | const b = 10 44 | const c = () => console.log('foo') 45 | 46 | if(a > 10) { 47 | console.log('bar') 48 | } 49 | 50 | console.log('done') 51 | `; 52 | const newCode = ` 53 | const a = 10 54 | const boo = 10 55 | 56 | if(a === 10) { 57 | console.log('bar') 58 | } 59 | `; 60 | 61 | class Diff extends PureComponent { 62 | render = () => { 63 | return ( 64 | 65 | ); 66 | }; 67 | } 68 | ``` 69 | 70 | ## Props 71 | 72 | | Prop | Type | Default | Description | 73 | |---------------------------|---------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 74 | | oldValue | `string \| Object` | `''` | Old value as string (or Object if using `diffJson`). | 75 | | newValue | `string \| Object` | `''` | New value as string (or Object if using `diffJson`). | 76 | | splitView | `boolean` | `true` | Switch between `unified` and `split` view. | 77 | | disableWordDiff | `boolean` | `false` | Show and hide word diff in a diff line. | 78 | | compareMethod | `DiffMethod \| (string, string) => diff.Change[]` | `DiffMethod.CHARS` | Uses an existing diff method when a `DiffMethod` enum is passed. If a function is passed, that function is used as the diff method.

JsDiff text diff method used for diffing strings. Check out the [guide](https://github.com/praneshr/react-diff-viewer/tree/v3.0.0#text-block-diff-comparison) to use different methods. | 79 | | renderGutter | `(diffData) => ReactNode` | `undefined` | Function that can be used to render an extra gutter with various information next to the line number. | 80 | | hideLineNumbers | `boolean` | `false` | Show and hide line numbers. | 81 | | alwaysShowLines | `string[]` | `[]` | List of lines to always be shown, regardless of diff status. Line number are prefixed with `L` and `R` for the left and right section of the diff viewer, respectively. For example, `L-20` means 20th line in the left pane. `extraLinesSurroundingDiff` applies to these lines as well. | 82 | | renderContent | `function` | `undefined` | Render Prop API to render code in the diff viewer. Helpful for [syntax highlighting](#syntax-highlighting) | 83 | | onLineNumberClick | `function` | `undefined` | Event handler for line number click. `(lineId: string) => void` | 84 | | highlightLines | `string[]` | `[]` | List of lines to be highlighted. Works together with `onLineNumberClick`. Line number are prefixed with `L` and `R` for the left and right section of the diff viewer, respectively. For example, `L-20` means 20th line in the left pane. To highlight a range of line numbers, pass the prefixed line number as an array. For example, `[L-2, L-3, L-4, L-5]` will highlight the lines `2-5` in the left pane. | 85 | | showDiffOnly | `boolean` | `true` | Shows only the diffed lines and folds the unchanged lines | 86 | | extraLinesSurroundingDiff | `number` | `3` | Number of extra unchanged lines surrounding the diff. Works along with `showDiffOnly`. | 87 | | codeFoldMessageRenderer | `function` | `Expand {number} of lines ...` | Render Prop API to render code fold message. | 88 | | styles | `object` | `{}` | To override style variables and styles. Learn more about [overriding styles](#overriding-styles) | 89 | | useDarkTheme | `boolean` | `true` | To enable/disable dark theme. | 90 | | leftTitle | `string` | `undefined` | Column title for left section of the diff in split view. This will be used as the only title in inline view. | 91 | | rightTitle | `string` | `undefined` | Column title for right section of the diff in split view. This will be ignored in inline view. | 92 | | linesOffset | `number` | `0` | Number to start count code lines from. | 93 | 94 | ## Instance Methods 95 | 96 | `resetCodeBlocks()` - Resets the expanded code blocks to it's initial state. Return `true` on successful reset and `false` during unsuccessful reset. 97 | 98 | ## Syntax Highlighting 99 | 100 | Syntax highlighting is a bit tricky when combined with diff. Here, React Diff Viewer provides a simple render prop API to handle syntax highlighting. Use `renderContent(content: string) => JSX.Element` and your favorite syntax highlighting library to achieve this. 101 | 102 | An example using [Prism JS](https://prismjs.com) 103 | 104 | ```html 105 | // Load Prism CSS 106 | 109 | 110 | // Load Prism JS 111 | 112 | ``` 113 | 114 | ```javascript 115 | import React, { PureComponent } from 'react'; 116 | import ReactDiffViewer from 'react-diff-viewer'; 117 | 118 | const oldCode = ` 119 | const a = 10 120 | const b = 10 121 | const c = () => console.log('foo') 122 | 123 | if(a > 10) { 124 | console.log('bar') 125 | } 126 | 127 | console.log('done') 128 | `; 129 | const newCode = ` 130 | const a = 10 131 | const boo = 10 132 | 133 | if(a === 10) { 134 | console.log('bar') 135 | } 136 | `; 137 | 138 | class Diff extends PureComponent { 139 | highlightSyntax = (str) => ( 140 |
146 |   );
147 | 
148 |   render = () => {
149 |     return (
150 |       
156 |     );
157 |   };
158 | }
159 | ```
160 | 
161 | ## Text block diff comparison
162 | 
163 | Different styles of text block diffing are possible by using the enums corresponding to variou JsDiff methods ([learn more](https://github.com/kpdecker/jsdiff/tree/v4.0.1#api)). The supported methods are as follows.
164 | 
165 | ```javascript
166 | enum DiffMethod {
167 |   CHARS = 'diffChars',
168 |   WORDS = 'diffWords',
169 |   WORDS_WITH_SPACE = 'diffWordsWithSpace',
170 |   LINES = 'diffLines',
171 |   TRIMMED_LINES = 'diffTrimmedLines',
172 |   SENTENCES = 'diffSentences',
173 |   CSS = 'diffCss',
174 | }
175 | ```
176 | 
177 | ```javascript
178 | import React, { PureComponent } from 'react';
179 | import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer';
180 | 
181 | const oldCode = `
182 | {
183 |   "name": "Original name",
184 |   "description": null
185 | }
186 | `;
187 | const newCode = `
188 | {
189 |   "name": "My updated name",
190 |   "description": "Brand new description",
191 |   "status": "running"
192 | }
193 | `;
194 | 
195 | class Diff extends PureComponent {
196 |   render = () => {
197 |     return (
198 |       
204 |     );
205 |   };
206 | }
207 | ```
208 | 
209 | ## Overriding Styles
210 | 
211 | React Diff Viewer uses [emotion](https://emotion.sh/) for styling. It also offers a simple way to override styles and style variables. You can supply different variables for both light and dark themes. Styles will be common for both themes.
212 | 
213 | Below are the default style variables and style object keys.
214 | 
215 | ```javascript
216 | 
217 | // Default variables and style keys
218 | 
219 | const defaultStyles = {
220 |   variables: {
221 |     light: {
222 |       diffViewerBackground: '#fff',
223 |       diffViewerColor: '#212529',
224 |       addedBackground: '#e6ffed',
225 |       addedColor: '#24292e',
226 |       removedBackground: '#ffeef0',
227 |       removedColor: '#24292e',
228 |       wordAddedBackground: '#acf2bd',
229 |       wordRemovedBackground: '#fdb8c0',
230 |       addedGutterBackground: '#cdffd8',
231 |       removedGutterBackground: '#ffdce0',
232 |       gutterBackground: '#f7f7f7',
233 |       gutterBackgroundDark: '#f3f1f1',
234 |       highlightBackground: '#fffbdd',
235 |       highlightGutterBackground: '#fff5b1',
236 |       codeFoldGutterBackground: '#dbedff',
237 |       codeFoldBackground: '#f1f8ff',
238 |       emptyLineBackground: '#fafbfc',
239 |       gutterColor: '#212529',
240 |       addedGutterColor: '#212529',
241 |       removedGutterColor: '#212529',
242 |       codeFoldContentColor: '#212529',
243 |       diffViewerTitleBackground: '#fafbfc',
244 |       diffViewerTitleColor: '#212529',
245 |       diffViewerTitleBorderColor: '#eee',
246 |     },
247 |     dark: {
248 |       diffViewerBackground: '#2e303c',
249 |       diffViewerColor: '#FFF',
250 |       addedBackground: '#044B53',
251 |       addedColor: 'white',
252 |       removedBackground: '#632F34',
253 |       removedColor: 'white',
254 |       wordAddedBackground: '#055d67',
255 |       wordRemovedBackground: '#7d383f',
256 |       addedGutterBackground: '#034148',
257 |       removedGutterBackground: '#632b30',
258 |       gutterBackground: '#2c2f3a',
259 |       gutterBackgroundDark: '#262933',
260 |       highlightBackground: '#2a3967',
261 |       highlightGutterBackground: '#2d4077',
262 |       codeFoldGutterBackground: '#21232b',
263 |       codeFoldBackground: '#262831',
264 |       emptyLineBackground: '#363946',
265 |       gutterColor: '#464c67',
266 |       addedGutterColor: '#8c8c8c',
267 |       removedGutterColor: '#8c8c8c',
268 |       codeFoldContentColor: '#555a7b',
269 |       diffViewerTitleBackground: '#2f323e',
270 |       diffViewerTitleColor: '#555a7b',
271 |       diffViewerTitleBorderColor: '#353846',
272 |     }
273 |   },
274 |   diffContainer?: {}, // style object
275 |   diffRemoved?: {}, // style object
276 |   diffAdded?: {}, // style object
277 |   marker?: {}, // style object
278 |   emptyGutter?: {}, // style object
279 |   highlightedLine?: {}, // style object
280 |   lineNumber?: {}, // style object
281 |   highlightedGutter?: {}, // style object
282 |   contentText?: {}, // style object
283 |   gutter?: {}, // style object
284 |   line?: {}, // style object
285 |   wordDiff?: {}, // style object
286 |   wordAdded?: {}, // style object
287 |   wordRemoved?: {}, // style object
288 |   codeFoldGutter?: {}, // style object
289 |   codeFold?: {}, // style object
290 |   emptyLine?: {}, // style object
291 |   content?: {}, // style object
292 |   titleBlock?: {}, // style object
293 |   splitView?: {}, // style object
294 | }
295 | ```
296 | 
297 | To override any style, just pass the new style object to the `styles` prop. New style will be computed using `Object.assign(default, override)`.
298 | 
299 | For keys other than `variables`, the value can either be an object or string interpolation.
300 | 
301 | ```javascript
302 | import React, { PureComponent } from 'react';
303 | import ReactDiffViewer from 'react-diff-viewer';
304 | 
305 | const oldCode = `
306 | const a = 10
307 | const b = 10
308 | const c = () => console.log('foo')
309 | 
310 | if(a > 10) {
311 |   console.log('bar')
312 | }
313 | 
314 | console.log('done')
315 | `;
316 | const newCode = `
317 | const a = 10
318 | const boo = 10
319 | 
320 | if(a === 10) {
321 |   console.log('bar')
322 | }
323 | `;
324 | 
325 | class Diff extends PureComponent {
326 |   highlightSyntax = (str) => (
327 |     
333 |   );
334 | 
335 |   render = () => {
336 |     const newStyles = {
337 |       variables: {
338 |         dark: {
339 |           highlightBackground: '#fefed5',
340 |           highlightGutterBackground: '#ffcd3c',
341 |         },
342 |       },
343 |       line: {
344 |         padding: '10px 2px',
345 |         '&:hover': {
346 |           background: '#a26ea1',
347 |         },
348 |       },
349 |     };
350 | 
351 |     return (
352 |       
359 |     );
360 |   };
361 | }
362 | ```
363 | 
364 | ## Local Development
365 | 
366 | ```bash
367 | pnpm install
368 | pnpm build # or use yarn build:watch
369 | pnpm start:examples
370 | ```
371 | 
372 | Check package.json for more build scripts.
373 | 
374 | ## Contributors
375 | 
376 | 
377 | 
378 | 
379 | 
380 |   
381 |     
382 |       
383 |       
384 |       
385 |     
386 |   
387 | 
Eric M.
Eric M.

💻
Andrei Kovalevsky
Andrei Kovalevsky

💻
Chang Hyun Kim
Chang Hyun Kim

💻
388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | ## License 402 | 403 | MIT 404 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | '@babel/preset-react', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatter": { 3 | "indentStyle": "space", 4 | "indentWidth": 2 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "a11y": { 10 | "useKeyWithClickEvents": "off" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/example.jpg -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Diff Viewer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /examples/src/diff/javascript/new.rjs: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | var WriteFilePlugin = require('write-file-webpack-plugin'); 6 | const additionalConfiguration = { 7 | contentBase: path.resolve(__dirname, './app'), 8 | reloadModules: true, 9 | } 10 | module.exports = { 11 | entry: [ 12 | 'webpack/hot/dev-server', 13 | 'webpack-hot-middleware/client?reload=true', 14 | path.join(__dirname, 'app/main.js') 15 | ], 16 | output: { 17 | path: path.join(__dirname, '/dist/'), 18 | filename: '[name].js', 19 | publicPath: '/' 20 | }, 21 | module: { 22 | loaders: [ 23 | { 24 | test: /\.js?$/, 25 | loader: 'babel', 26 | exclude: /node_modules|lib/, 27 | }, 28 | { 29 | test: /\.json?$/, 30 | loader: 'json' 31 | }, 32 | { 33 | test: /\.css$/, 34 | loader: 'style!css?modules&localIdentName=[hash:base64:5]' 35 | }, 36 | { 37 | test: /\.scss$/, 38 | loaders: [ 39 | 'style?sourceMap', 40 | 'css?modules&importLoaders=1', 41 | 'sass?sourceMap' 42 | ], 43 | exclude: /node_modules|lib/ 44 | }, 45 | ], 46 | }, 47 | trailingSpaces: '', 48 | test: "this is an incredibly long string that should be broken up into multiple lines to make it easier to read and maintain. This is a test of the emergency broadcast system. This is only a test. If this were a real emergency, you would be instructed to do something else. But it's not, so you're not. You're just reading a long string. Sorry. ", 49 | plugins: [ 50 | new WriteFilePlugin(), 51 | new ExtractTextPlugin('app.css', { 52 | allChunks: true 53 | }), 54 | new HtmlWebpackPlugin({ 55 | template: 'app/index.tpl.html', 56 | inject: 'body', 57 | filename: 'index.html' 58 | }), 59 | new webpack.optimize.OccurenceOrderPlugin(), 60 | new webpack.HotModuleReplacementPlugin(), 61 | new webpack.NoErrorsPlugin(), 62 | new webpack.DefinePlugin({ 63 | 'process.env.NODE_ENV': JSON.stringify('dev') 64 | }) 65 | ], 66 | node: { 67 | fs: 'empty' 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /examples/src/diff/javascript/old.rjs: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | var WriteFilePlugin = require('write-file-webpack-plugin'); 6 | const devServer = { 7 | contentBase: path.resolve(__dirname, './app'), 8 | outputPath: path.join(__dirname, './dist'), 9 | colors: true, 10 | quiet: false, 11 | noInfo: false, 12 | publicPath: '/', 13 | historyApiFallback: false, 14 | host: '127.0.0.1', 15 | proxy:{ 16 | '/graphql': { 17 | target: 'http://localhost:8080' 18 | } 19 | }, 20 | port: 3000, 21 | hot: true 22 | }; 23 | module.exports = { 24 | devtool: 'eval-source-map', 25 | debug: true, 26 | devServer: devServer, 27 | entry: [ 28 | 'webpack/hot/dev-server', 29 | 'webpack-hot-middleware/client?reload=true', 30 | path.join(__dirname, 'app/main.js') 31 | ], 32 | output: { 33 | path: path.join(__dirname, '/dist/'), 34 | filename: '[name].js', 35 | publicPath: devServer.publicPath 36 | }, 37 | module: { 38 | loaders: [ 39 | { 40 | test: /\.js?$/, 41 | loader: 'babel', 42 | exclude: /node_modules|lib/, 43 | }, 44 | { 45 | test: /\.json?$/, 46 | loader: 'json' 47 | }, 48 | { 49 | test: /\.css$/, 50 | loader: 'style!css?modules&localIdentName=[name]---[local]---[hash:base64:5]' 51 | }, 52 | { 53 | test: /\.scss$/, 54 | loaders: [ 55 | 'style?sourceMap', 56 | 'css?modules&importLoaders=1&localIdentName=[name]--[local]', 57 | 'sass?sourceMap' 58 | ], 59 | exclude: /node_modules|lib/ 60 | }, 61 | ], 62 | }, 63 | trailingSpaces: '', 64 | plugins: [ 65 | new WriteFilePlugin(), 66 | new ExtractTextPlugin('app.css', { 67 | allChunks: true 68 | }), 69 | new HtmlWebpackPlugin({ 70 | template: 'app/index.tpl.html', 71 | inject: 'body', 72 | filename: 'index.html' 73 | }), 74 | ], 75 | node: { 76 | fs: 'empty' 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /examples/src/diff/json/new.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-diff-viewer", 3 | "license": "BSD", 4 | "version": "1.0.0", 5 | "description": "Text diff viewer for React", 6 | "main": "lib/index.js", 7 | "repository": "git@github.com:praneshr/react-diff-viewer.git", 8 | "author": "Pranesh Ravi", 9 | "private": false, 10 | "scripts": { 11 | "build": "tsc --outDir lib/", 12 | "build:watch": "tsc --outDir lib/ -w", 13 | "start:examples": "webpack-dev-server --open --hot --inline", 14 | "start": "webpack-dev-server --open --hot --inline" 15 | }, 16 | "devDependencies": { 17 | "@types/classnames": "^2.2.6", 18 | "@types/diff": "^3.5.1", 19 | "@types/react": "^16.4.14", 20 | "@types/react-dom": "^16.0.8", 21 | "@types/webpack": "^4.4.13", 22 | "css-loader": "^1.0.0", 23 | "html-webpack-plugin": "^3.2.0", 24 | "mini-css-extract-plugin": "^0.4.3", 25 | "node-sass": "^4.9.3", 26 | "react": "^16.5.2", 27 | "react-dom": "^16.5.2", 28 | "sass-loader": "^7.1.0", 29 | "ts-loader": "^5.2.1", 30 | "typescript": "^3.1.1", 31 | "webpack": "^4.20.2", 32 | "webpack-cli": "^3.1.1", 33 | "webpack-dev-server": "^3.1.9" 34 | }, 35 | "dependencies": { 36 | "classnames": "^2.2.6" 37 | }, 38 | "peerDependencies": { 39 | "react": "^16.5.2", 40 | "react-dom": "^16.5.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/src/diff/json/old.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@types/classnames": "^2.2.6", 4 | "@types/diff": "^3.5.1", 5 | "@types/react": "^16.4.14", 6 | "@types/react-dom": "^16.0.8", 7 | "@types/webpack": "^4.4.13", 8 | "css-loader": "^1.0.0", 9 | "html-webpack-plugin": "^3.2.0", 10 | "mini-css-extract-plugin": "^0.4.3", 11 | "node-sass": "^4.9.3", 12 | "react": "^16.5.2", 13 | "react-dom": "^16.5.2", 14 | "sass-loader": "^7.1.0", 15 | "ts-loader": "^5.2.1", 16 | "typescript": "^3.1.1", 17 | "webpack": "^4.20.2", 18 | "webpack-cli": "^3.1.1", 19 | "webpack-dev-server": "^3.1.9" 20 | }, 21 | "name": "react-diff-viewer", 22 | "version": "1.0.0", 23 | "description": "Text diff viewer for React", 24 | "main": "lib/index.js", 25 | "repository": "git@github.com:praneshr/react-diff-viewer.git", 26 | "author": "Pranesh Ravi", 27 | "license": "MIT", 28 | "private": false, 29 | "scripts": { 30 | "build": "tsc --outDir lib/", 31 | "build:watch": "tsc --outDir lib/ -w", 32 | "start:examples": "webpack-dev-server --open --hot --inline" 33 | }, 34 | "dependencies": { 35 | "classnames": "^2.2.6", 36 | "prop-types": "^15.6.2", 37 | "emotion": "^9.2.10", 38 | "diff": "^3.5.0" 39 | }, 40 | "peerDependencies": { 41 | "react": "^16.5.2", 42 | "react-dom": "^16.5.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/src/diff/xml/new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | QWZ5671 8 | 39.95 9 | 10 | Red 11 | Burgundy 12 | 13 | 14 | Red 15 | Burgundy 16 | 17 | 18 | 19 | RRX9856 20 | 42.50 21 | 22 | Red 23 | Navy 24 | Burgundy 25 | Black 26 | 27 | 28 | Navy 29 | Black 30 | 31 | 32 | Burgundy 33 | Black 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /examples/src/diff/xml/old.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | QWZ5671 8 | 33 9 | 10 | Red 11 | Burgundy 12 | 13 | 14 | 15 | RRX9856 16 | 42.50 17 | 18 | Red 19 | Navy 20 | Burgundy 21 | 22 | 23 | Red 24 | Navy 25 | Burgundy 26 | Black 27 | 28 | 29 | Navy 30 | Black 31 | 32 | 33 | Burgundy 34 | Black 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import {Component, MouseEvent} from 'react'; 3 | 4 | import ReactDiff, {DiffMethod} from '../../src/index'; 5 | import logo from '../../logo.png'; 6 | import cn from 'classnames'; 7 | import {createRoot} from "react-dom/client"; 8 | 9 | import oldJs from './diff/javascript/old.rjs?raw'; 10 | import newJs from './diff/javascript/new.rjs?raw'; 11 | 12 | import oldYaml from './diff/massive/old.yaml?raw'; 13 | import newYaml from './diff/massive/new.yaml?raw'; 14 | 15 | import oldJson from './diff/json/old.json'; 16 | import newJson from './diff/json/new.json'; 17 | 18 | interface ExampleState { 19 | splitView?: boolean; 20 | highlightLine?: string[]; 21 | language?: string; 22 | lineNumbers: boolean; 23 | theme: 'dark' | 'light'; 24 | enableSyntaxHighlighting?: boolean; 25 | columnHeaders: boolean; 26 | compareMethod?: DiffMethod; 27 | dataType: string; 28 | customGutter?: boolean; 29 | } 30 | 31 | const P = (window as any).Prism; 32 | 33 | class Example extends Component<{}, ExampleState> { 34 | public constructor(props: any) { 35 | super(props); 36 | this.state = { 37 | highlightLine: [], 38 | theme: 'dark', 39 | splitView: true, 40 | columnHeaders: true, 41 | lineNumbers: true, 42 | customGutter: true, 43 | enableSyntaxHighlighting: true, 44 | dataType: 'javascript', 45 | compareMethod: DiffMethod.CHARS 46 | }; 47 | } 48 | 49 | private onLineNumberClick = ( 50 | id: string, 51 | e: MouseEvent, 52 | ): void => { 53 | let highlightLine = [id]; 54 | if (e.shiftKey && this.state.highlightLine.length === 1) { 55 | const [dir, oldId] = this.state.highlightLine[0].split('-'); 56 | const [newDir, newId] = id.split('-'); 57 | if (dir === newDir) { 58 | highlightLine = []; 59 | const lowEnd = Math.min(Number(oldId), Number(newId)); 60 | const highEnd = Math.max(Number(oldId), Number(newId)); 61 | for (let i = lowEnd; i <= highEnd; i++) { 62 | highlightLine.push(`${dir}-${i}`); 63 | } 64 | } 65 | } 66 | this.setState({ 67 | highlightLine, 68 | }); 69 | }; 70 | 71 | private syntaxHighlight = (str: string): any => { 72 | if (!str) return; 73 | const language = P.highlight(str, P.languages.javascript); 74 | return ; 75 | }; 76 | 77 | public render(): JSX.Element { 78 | let oldValue: string | Record = '' 79 | let newValue: string | Record = ''; 80 | if (this.state.dataType === 'json') { 81 | oldValue = oldJson 82 | newValue = newJson 83 | } else if (this.state.dataType === 'javascript') { 84 | oldValue = oldJs 85 | newValue = newJs 86 | } else { 87 | oldValue = oldYaml 88 | newValue = newYaml 89 | } 90 | 91 | return ( 92 |
93 |
94 |
95 |
96 | React Diff Viewer Logo 97 |
98 |

99 | A simple and beautiful text diff viewer made with{' '} 100 | 101 | Diff{' '} 102 | 103 | and{' '} 104 | 105 | React.{' '} 106 | 107 | Featuring split view, inline view, word diff, line highlight and 108 | more. 109 |

110 |

111 | This documentation is for the `next` release branch, e.g. v4.x 112 |

113 | 120 | 121 |
122 |
123 | 140 | Dark theme 141 |
142 |
143 | 155 | Split pane 156 |
157 |
158 | 171 | Syntax highlighting 172 |
173 |
174 | 187 | Column Headers 188 |
189 |
190 | 202 | Custom gutter 203 |
204 |
205 | 217 | Line Numbers 218 |
219 |
220 | 235 | Data 236 |
237 |
238 |
239 |
240 | { 253 | return ( 254 | 266 |
267 |                           {diffData.type == 3
268 |                             ? 'CHG'
269 |                             : diffData.type == 2
270 |                             ? 'DEL'
271 |                             : diffData.type == 1
272 |                             ? 'ADD'
273 |                             : diffData.type
274 |                             ? '==='
275 |                             : undefined}
276 |                         
277 | 278 | ); 279 | } 280 | : undefined 281 | } 282 | renderContent={ 283 | this.state.enableSyntaxHighlighting 284 | ? this.syntaxHighlight 285 | : undefined 286 | } 287 | useDarkTheme={this.state.theme === 'dark'} 288 | summary={this.state.compareMethod === DiffMethod.JSON ? 'package.json' : 'webpack.config.js'} 289 | leftTitle={this.state.columnHeaders ? `master@2178133 - pushed 2 hours ago.` : undefined} 290 | rightTitle={this.state.columnHeaders ? `master@64207ee - pushed 13 hours ago.` : undefined} 291 | /> 292 |
293 | 303 |
304 | ); 305 | } 306 | } 307 | 308 | const root = createRoot(document.getElementById('app')); 309 | root.render(); 310 | -------------------------------------------------------------------------------- /examples/src/style.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * GHColors theme by Avi Aryan (http://aviaryan.in) 3 | * Inspired by Github syntax coloring 4 | */ 5 | 6 | code[class*='language-'], 7 | pre[class*='language-'] { 8 | color: #393a34; 9 | font-family: 'Consolas', 'Bitstream Vera Sans Mono', 'Courier New', Courier, 10 | monospace; 11 | direction: ltr; 12 | text-align: left; 13 | white-space: pre; 14 | word-spacing: normal; 15 | word-break: normal; 16 | font-size: 0.95em; 17 | line-height: 1.2em; 18 | -moz-tab-size: 4; 19 | -o-tab-size: 4; 20 | tab-size: 4; 21 | -webkit-hyphens: none; 22 | -moz-hyphens: none; 23 | -ms-hyphens: none; 24 | hyphens: none; 25 | } 26 | 27 | pre[class*='language-']::-moz-selection, 28 | pre[class*='language-'] ::-moz-selection, 29 | code[class*='language-']::-moz-selection, 30 | code[class*='language-'] ::-moz-selection { 31 | background: #b3d4fc; 32 | } 33 | 34 | pre[class*='language-']::selection, 35 | pre[class*='language-'] ::selection, 36 | code[class*='language-']::selection, 37 | code[class*='language-'] ::selection { 38 | background: #b3d4fc; 39 | } 40 | 41 | /* Code blocks */ 42 | 43 | pre[class*='language-'] { 44 | padding: 1em; 45 | margin: 0.5em 0; 46 | overflow: auto; 47 | border: 1px solid #dddddd; 48 | background-color: white; 49 | } 50 | 51 | :not(pre) > code[class*='language-'], 52 | pre[class*='language-'] { 53 | } 54 | 55 | /* Inline code */ 56 | 57 | :not(pre) > code[class*='language-'] { 58 | padding: 0.2em; 59 | padding-top: 1px; 60 | padding-bottom: 1px; 61 | background: #f8f8f8; 62 | border: 1px solid #dddddd; 63 | } 64 | 65 | .token.comment, 66 | .token.prolog, 67 | .token.doctype, 68 | .token.cdata { 69 | color: #999988; 70 | font-style: italic; 71 | } 72 | 73 | .token.namespace { 74 | opacity: 0.7; 75 | } 76 | 77 | .token.string, 78 | .token.attr-value { 79 | color: #e3116c; 80 | } 81 | 82 | .token.punctuation, 83 | .token.operator { 84 | color: #3bf5d4; 85 | /* no highlight */ 86 | } 87 | 88 | .token.entity, 89 | .token.url, 90 | .token.symbol, 91 | .token.number, 92 | .token.boolean, 93 | .token.variable, 94 | .token.constant, 95 | .token.property, 96 | .token.regex, 97 | .token.inserted { 98 | color: #36acaa; 99 | } 100 | 101 | .token.atrule, 102 | .token.keyword, 103 | .token.attr-name, 104 | .language-autohotkey .token.selector { 105 | color: #00a4db; 106 | } 107 | 108 | .token.function, 109 | .token.deleted, 110 | .language-autohotkey .token.tag { 111 | color: #069071; 112 | } 113 | 114 | .token.tag, 115 | .token.selector, 116 | .language-autohotkey .token.keyword { 117 | color: #ff9292; 118 | } 119 | 120 | .token.important, 121 | .token.function, 122 | .token.bold { 123 | font-weight: bold; 124 | } 125 | 126 | .token.italic { 127 | font-style: italic; 128 | } 129 | 130 | @import url('https://fonts.googleapis.com/css?family=Poppins:400,500,600,700,800'); 131 | 132 | body { 133 | font-family: 'Poppins', sans-serif; 134 | background-color: #262831; 135 | } 136 | body.light { 137 | background-color: #eee; 138 | 139 | .radial { 140 | background: linear-gradient(180deg, #dab 0%, #eee 100%); 141 | position: absolute; 142 | width: 100%; 143 | height: 600px; 144 | left: 0; 145 | z-index: -1; 146 | } 147 | .banner p { 148 | color: #444; 149 | } 150 | .options { 151 | color: #444; 152 | } 153 | } 154 | 155 | .options { 156 | margin-top: 4em; 157 | display: flex; 158 | flex-wrap: wrap; 159 | flex-direction: row; 160 | justify-content: center; 161 | gap: 2em; 162 | 163 | div { 164 | display: flex; 165 | align-items: center; 166 | gap: 8px; 167 | } 168 | 169 | color: #eee; 170 | } 171 | 172 | .select { 173 | select { 174 | border-radius: 50px; 175 | padding: 4px 12px; 176 | border: 2px solid #125dec; 177 | background: white; 178 | } 179 | } 180 | 181 | .react-diff-viewer-example { 182 | a { 183 | color: #125dec; 184 | text-decoration: none; 185 | } 186 | 187 | .banner { 188 | padding: 70px 15px; 189 | text-align: center; 190 | 191 | .img-container { 192 | text-align: center; 193 | margin: 100px auto 60px; 194 | max-width: 700px; 195 | 196 | img { 197 | width: 100%; 198 | 199 | &.mobile { 200 | display: none; 201 | } 202 | } 203 | } 204 | 205 | .cta { 206 | margin-top: 60px; 207 | 208 | a { 209 | &:last-child { 210 | button { 211 | margin-right: 0; 212 | } 213 | } 214 | } 215 | 216 | button { 217 | font-size: 14px; 218 | background: #125dec; 219 | border: none; 220 | cursor: pointer; 221 | 222 | &:focus { 223 | background: #125dec; 224 | } 225 | } 226 | } 227 | 228 | p { 229 | max-width: 700px; 230 | font-size: 18px; 231 | margin: 0 auto; 232 | color: #fff; 233 | } 234 | } 235 | 236 | .controls { 237 | margin: 50px 15px 15px; 238 | display: flex; 239 | align-items: center; 240 | justify-content: space-between; 241 | 242 | label { 243 | margin-left: 30px; 244 | } 245 | 246 | select { 247 | background-color: transparent; 248 | padding: 5px 15px; 249 | border-radius: 4px; 250 | border: 2px solid #ddd; 251 | } 252 | } 253 | 254 | .radial { 255 | background: linear-gradient(180deg, #363946 0%, #262931 100%); 256 | position: absolute; 257 | width: 100%; 258 | height: 600px; 259 | left: 0; 260 | z-index: -1; 261 | } 262 | 263 | .diff-viewer { 264 | max-width: 90%; 265 | margin: 0 auto; 266 | border-radius: 8px; 267 | overflow-x: auto; 268 | overflow-y: hidden; 269 | white-space: nowrap; 270 | box-shadow: 0 0 30px #1c1e25; 271 | 272 | a { 273 | color: inherit; 274 | } 275 | } 276 | 277 | footer { 278 | margin: 40px 0; 279 | color: #fff; 280 | text-align: center; 281 | } 282 | } 283 | 284 | @media (max-width: 1023px) { 285 | .react-diff-viewer-example { 286 | .banner { 287 | .img-container { 288 | img { 289 | width: 80%; 290 | } 291 | } 292 | } 293 | 294 | p { 295 | font-size: 16px; 296 | } 297 | } 298 | } 299 | 300 | /* The switch - the box around the slider */ 301 | .switch { 302 | position: relative; 303 | display: inline-block; 304 | width: 60px; 305 | height: 34px; 306 | margin-bottom: 0; 307 | } 308 | 309 | /* Hide default HTML checkbox */ 310 | .switch input { 311 | opacity: 0; 312 | width: 0; 313 | height: 0; 314 | } 315 | 316 | /* The slider */ 317 | .slider { 318 | position: absolute; 319 | cursor: pointer; 320 | top: 0; 321 | left: 0; 322 | right: 0; 323 | bottom: 0; 324 | background-color: #ccc; 325 | -webkit-transition: 0.4s; 326 | transition: 0.4s; 327 | } 328 | 329 | .slider:before { 330 | position: absolute; 331 | content: ''; 332 | height: 26px; 333 | width: 26px; 334 | left: 4px; 335 | bottom: 4px; 336 | background-color: white; 337 | -webkit-transition: 0.4s; 338 | transition: 0.4s; 339 | } 340 | 341 | input:checked + .slider { 342 | background-color: #125dec; 343 | } 344 | 345 | input:focus + .slider { 346 | box-shadow: 0 0 1px #125dec; 347 | } 348 | 349 | input:checked + .slider:before { 350 | -webkit-transform: translateX(26px); 351 | -ms-transform: translateX(26px); 352 | transform: translateX(26px); 353 | } 354 | 355 | /* Rounded sliders */ 356 | .slider.round { 357 | border-radius: 34px; 358 | } 359 | 360 | .slider.round:before { 361 | border-radius: 50%; 362 | } 363 | -------------------------------------------------------------------------------- /examples/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: any; 3 | export = value; 4 | } 5 | -------------------------------------------------------------------------------- /examples/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | }) 5 | -------------------------------------------------------------------------------- /logo-standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/logo-standalone.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/logo.png -------------------------------------------------------------------------------- /logo.png~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/logo.png~ -------------------------------------------------------------------------------- /logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeolun/react-diff-viewer-continued/328154ee0d6dbd8984727be654ff90b263b957bc/logo_dark.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-diff-viewer-continued", 3 | "version": "4.0.6", 4 | "private": false, 5 | "description": "Continuation of a simple and beautiful text diff viewer component made with diff and React", 6 | "keywords": [ 7 | "review", 8 | "code-review", 9 | "diff", 10 | "diff-viewer", 11 | "github", 12 | "react", 13 | "react-component", 14 | "ui" 15 | ], 16 | "repository": "git@github.com:aeolun/react-diff-viewer-continued.git", 17 | "license": "MIT", 18 | "authors": [ 19 | "Pranesh Ravi", 20 | "Bart Riepe " 21 | ], 22 | "type": "module", 23 | "exports": { 24 | ".": { 25 | "types": "./lib/cjs/src/index.d.ts", 26 | "import": "./lib/esm/src/index.js", 27 | "require": "./lib/cjs/src/index.js" 28 | } 29 | }, 30 | "main": "lib/cjs/src/index", 31 | "module": "lib/esm/src/index", 32 | "typings": "lib/cjs/src/index", 33 | "scripts": { 34 | "build": "tsc --project tsconfig.json && tsc --project tsconfig.esm.json", 35 | "build:examples": "vite build examples", 36 | "publish:examples": "NODE_ENV=production pnpm run build:examples && gh-pages -d examples/dist -r $GITHUB_REPO_URL", 37 | "publish:examples:local": "NODE_ENV=production pnpm run build:examples && gh-pages -d examples/dist", 38 | "start:examples": "vite examples", 39 | "dev": "vite dev examples", 40 | "test": "vitest", 41 | "check": "biome check src/ test/", 42 | "check:fix": "biome check --write --unsafe src/ test/" 43 | }, 44 | "dependencies": { 45 | "@emotion/css": "^11.13.5", 46 | "@emotion/react": "^11.14.0", 47 | "classnames": "^2.5.1", 48 | "diff": "^5.2.0", 49 | "memoize-one": "^6.0.0" 50 | }, 51 | "devDependencies": { 52 | "@biomejs/biome": "^1.9.4", 53 | "@semantic-release/changelog": "6.0.1", 54 | "@semantic-release/git": "10.0.1", 55 | "@testing-library/react": "^13.4.0", 56 | "@types/diff": "^5.2.3", 57 | "@types/node": "^20.17.16", 58 | "@types/react": "^18.3.18", 59 | "@types/react-dom": "^18.3.5", 60 | "gh-pages": "^5.0.0", 61 | "happy-dom": "^13.10.1", 62 | "react": "^18.3.1", 63 | "react-dom": "^18.3.1", 64 | "sass": "^1.83.4", 65 | "semantic-release": "^24.2.1", 66 | "ts-node": "^10.9.2", 67 | "typescript": "^5.7.3", 68 | "vite": "^5.4.14", 69 | "vitest": "^3.0.4" 70 | }, 71 | "peerDependencies": { 72 | "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 73 | "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 74 | }, 75 | "engines": { 76 | "node": ">= 16" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /publish-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$TRAVIS_TAG" ]; then 4 | echo "Publishing examples to Github pages...." 5 | git config --global user.email "travis@travis-ci.org" 6 | git config --global user.name "Travis CI" 7 | yarn publish:examples 8 | else 9 | echo "Skipping examples deployment..." 10 | fi 11 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/npm", 6 | "@semantic-release/changelog", 7 | "@semantic-release/github", 8 | [ 9 | "@semantic-release/git", 10 | { 11 | message: 12 | "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 13 | assets: [ 14 | "CHANGELOG.md", 15 | "package.json", 16 | "package-lock.json", 17 | "pnpm-lock.yaml", 18 | "npm-shrinkwrap.json", 19 | ], 20 | }, 21 | ], 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /src/compute-hidden-blocks.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { DiffType, type LineInformation } from "./compute-lines"; 3 | 4 | export interface Block { 5 | index: number; 6 | startLine: number; 7 | endLine: number; 8 | lines: number; 9 | } 10 | interface HiddenBlocks { 11 | lineBlocks: Record; 12 | blocks: Block[]; 13 | } 14 | export function computeHiddenBlocks( 15 | lineInformation: LineInformation[], 16 | diffLines: number[], 17 | extraLines: number, 18 | ): HiddenBlocks { 19 | let newBlockIndex = 0; 20 | let currentBlock: Block | undefined; 21 | const lineBlocks: Record = {}; 22 | const blocks: Block[] = []; 23 | lineInformation.forEach((line, lineIndex) => { 24 | const isDiffLine = diffLines.some( 25 | (diffLine) => 26 | diffLine >= lineIndex - extraLines && 27 | diffLine <= lineIndex + extraLines, 28 | ); 29 | if (!isDiffLine && currentBlock === undefined) { 30 | // block begins 31 | currentBlock = { 32 | index: newBlockIndex, 33 | startLine: lineIndex, 34 | endLine: lineIndex, 35 | lines: 1, 36 | }; 37 | blocks.push(currentBlock); 38 | lineBlocks[lineIndex] = currentBlock.index; 39 | newBlockIndex++; 40 | } else if (!isDiffLine && currentBlock) { 41 | // block continues 42 | currentBlock.endLine = lineIndex; 43 | currentBlock.lines++; 44 | lineBlocks[lineIndex] = currentBlock.index; 45 | } else { 46 | // not a block anymore 47 | currentBlock = undefined; 48 | } 49 | }); 50 | 51 | return { 52 | lineBlocks, 53 | blocks: blocks, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/compute-lines.ts: -------------------------------------------------------------------------------- 1 | import * as diff from "diff"; 2 | 3 | const jsDiff: { [key: string]: any } = diff; 4 | 5 | export enum DiffType { 6 | DEFAULT = 0, 7 | ADDED = 1, 8 | REMOVED = 2, 9 | CHANGED = 3, 10 | } 11 | 12 | // See https://github.com/kpdecker/jsdiff/tree/v4.0.1#api for more info on the below JsDiff methods 13 | export enum DiffMethod { 14 | CHARS = "diffChars", 15 | WORDS = "diffWords", 16 | WORDS_WITH_SPACE = "diffWordsWithSpace", 17 | LINES = "diffLines", 18 | TRIMMED_LINES = "diffTrimmedLines", 19 | SENTENCES = "diffSentences", 20 | CSS = "diffCss", 21 | JSON = "diffJson", 22 | } 23 | 24 | export interface DiffInformation { 25 | value?: string | DiffInformation[]; 26 | lineNumber?: number; 27 | type?: DiffType; 28 | } 29 | 30 | export interface LineInformation { 31 | left?: DiffInformation; 32 | right?: DiffInformation; 33 | } 34 | 35 | export interface ComputedLineInformation { 36 | lineInformation: LineInformation[]; 37 | diffLines: number[]; 38 | } 39 | 40 | export interface ComputedDiffInformation { 41 | left?: DiffInformation[]; 42 | right?: DiffInformation[]; 43 | } 44 | 45 | // See https://github.com/kpdecker/jsdiff/tree/v4.0.1#change-objects for more info on JsDiff 46 | // Change Objects 47 | export interface JsDiffChangeObject { 48 | added?: boolean; 49 | removed?: boolean; 50 | value?: string; 51 | } 52 | 53 | /** 54 | * Splits diff text by new line and computes final list of diff lines based on 55 | * conditions. 56 | * 57 | * @param value Diff text from the js diff module. 58 | */ 59 | const constructLines = (value: string): string[] => { 60 | if (value === "") return []; 61 | 62 | const lines = value.replace(/\n$/, "").split("\n"); 63 | 64 | return lines; 65 | }; 66 | 67 | /** 68 | * Computes word diff information in the line. 69 | * [TODO]: Consider adding options argument for JsDiff text block comparison 70 | * 71 | * @param oldValue Old word in the line. 72 | * @param newValue New word in the line. 73 | * @param compareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 74 | */ 75 | const computeDiff = ( 76 | oldValue: string | Record, 77 | newValue: string | Record, 78 | compareMethod: 79 | | DiffMethod 80 | | ((oldStr: string, newStr: string) => diff.Change[]) = DiffMethod.CHARS, 81 | ): ComputedDiffInformation => { 82 | const compareFunc = 83 | typeof compareMethod === "string" ? jsDiff[compareMethod] : compareMethod; 84 | const diffArray: JsDiffChangeObject[] = compareFunc(oldValue, newValue); 85 | const computedDiff: ComputedDiffInformation = { 86 | left: [], 87 | right: [], 88 | }; 89 | diffArray.forEach(({ added, removed, value }): DiffInformation => { 90 | const diffInformation: DiffInformation = {}; 91 | if (added) { 92 | diffInformation.type = DiffType.ADDED; 93 | diffInformation.value = value; 94 | computedDiff.right.push(diffInformation); 95 | } 96 | if (removed) { 97 | diffInformation.type = DiffType.REMOVED; 98 | diffInformation.value = value; 99 | computedDiff.left.push(diffInformation); 100 | } 101 | if (!removed && !added) { 102 | diffInformation.type = DiffType.DEFAULT; 103 | diffInformation.value = value; 104 | computedDiff.right.push(diffInformation); 105 | computedDiff.left.push(diffInformation); 106 | } 107 | return diffInformation; 108 | }); 109 | return computedDiff; 110 | }; 111 | 112 | /** 113 | * [TODO]: Think about moving common left and right value assignment to a 114 | * common place. Better readability? 115 | * 116 | * Computes line wise information based in the js diff information passed. Each 117 | * line contains information about left and right section. Left side denotes 118 | * deletion and right side denotes addition. 119 | * 120 | * @param oldString Old string to compare. 121 | * @param newString New string to compare with old string. 122 | * @param disableWordDiff Flag to enable/disable word diff. 123 | * @param lineCompareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 124 | * @param linesOffset line number to start counting from 125 | * @param showLines lines that are always shown, regardless of diff 126 | */ 127 | const computeLineInformation = ( 128 | oldString: string | Record, 129 | newString: string | Record, 130 | disableWordDiff = false, 131 | lineCompareMethod: 132 | | DiffMethod 133 | | ((oldStr: string, newStr: string) => diff.Change[]) = DiffMethod.CHARS, 134 | linesOffset = 0, 135 | showLines: string[] = [], 136 | ): ComputedLineInformation => { 137 | let diffArray: Diff.Change[] = []; 138 | 139 | // Use diffLines for strings, and diffJson for objects... 140 | if (typeof oldString === "string" && typeof newString === "string") { 141 | diffArray = diff.diffLines(oldString, newString, { 142 | newlineIsToken: false, 143 | ignoreWhitespace: false, 144 | ignoreCase: false, 145 | }); 146 | } else { 147 | diffArray = diff.diffJson(oldString, newString); 148 | } 149 | 150 | let rightLineNumber = linesOffset; 151 | let leftLineNumber = linesOffset; 152 | let lineInformation: LineInformation[] = []; 153 | let counter = 0; 154 | const diffLines: number[] = []; 155 | const ignoreDiffIndexes: string[] = []; 156 | const getLineInformation = ( 157 | value: string, 158 | diffIndex: number, 159 | added?: boolean, 160 | removed?: boolean, 161 | evaluateOnlyFirstLine?: boolean, 162 | ): LineInformation[] => { 163 | const lines = constructLines(value); 164 | 165 | return lines 166 | .map((line: string, lineIndex): LineInformation => { 167 | const left: DiffInformation = {}; 168 | const right: DiffInformation = {}; 169 | if ( 170 | ignoreDiffIndexes.includes(`${diffIndex}-${lineIndex}`) || 171 | (evaluateOnlyFirstLine && lineIndex !== 0) 172 | ) { 173 | return undefined; 174 | } 175 | if (added || removed) { 176 | let countAsChange = true; 177 | if (removed) { 178 | leftLineNumber += 1; 179 | left.lineNumber = leftLineNumber; 180 | left.type = DiffType.REMOVED; 181 | left.value = line || " "; 182 | // When the current line is of type REMOVED, check the next item in 183 | // the diff array whether it is of type ADDED. If true, the current 184 | // diff will be marked as both REMOVED and ADDED. Meaning, the 185 | // current line is a modification. 186 | const nextDiff = diffArray[diffIndex + 1]; 187 | if (nextDiff?.added) { 188 | const nextDiffLines = constructLines(nextDiff.value)[lineIndex]; 189 | if (nextDiffLines) { 190 | const nextDiffLineInfo = getLineInformation( 191 | nextDiffLines, 192 | diffIndex, 193 | true, 194 | false, 195 | true, 196 | ); 197 | 198 | const { 199 | value: rightValue, 200 | lineNumber, 201 | type, 202 | } = nextDiffLineInfo[0].right; 203 | 204 | // When identified as modification, push the next diff to ignore 205 | // list as the next value will be added in this line computation as 206 | // right and left values. 207 | ignoreDiffIndexes.push(`${diffIndex + 1}-${lineIndex}`); 208 | 209 | right.lineNumber = lineNumber; 210 | if (left.value === rightValue) { 211 | // The new value is exactly the same as the old 212 | countAsChange = false; 213 | right.type = 0; 214 | left.type = 0; 215 | right.value = rightValue; 216 | } else { 217 | right.type = type; 218 | // Do char level diff and assign the corresponding values to the 219 | // left and right diff information object. 220 | if (disableWordDiff) { 221 | right.value = rightValue; 222 | } else { 223 | const computedDiff = computeDiff( 224 | line, 225 | rightValue as string, 226 | lineCompareMethod, 227 | ); 228 | right.value = computedDiff.right; 229 | left.value = computedDiff.left; 230 | } 231 | } 232 | } 233 | } 234 | } else { 235 | rightLineNumber += 1; 236 | right.lineNumber = rightLineNumber; 237 | right.type = DiffType.ADDED; 238 | right.value = line; 239 | } 240 | if (countAsChange && !evaluateOnlyFirstLine) { 241 | if (!diffLines.includes(counter)) { 242 | diffLines.push(counter); 243 | } 244 | } 245 | } else { 246 | leftLineNumber += 1; 247 | rightLineNumber += 1; 248 | 249 | left.lineNumber = leftLineNumber; 250 | left.type = DiffType.DEFAULT; 251 | left.value = line; 252 | right.lineNumber = rightLineNumber; 253 | right.type = DiffType.DEFAULT; 254 | right.value = line; 255 | } 256 | 257 | if ( 258 | showLines?.includes(`L-${left.lineNumber}`) || 259 | (showLines?.includes(`R-${right.lineNumber}`) && 260 | !diffLines.includes(counter)) 261 | ) { 262 | diffLines.push(counter); 263 | } 264 | 265 | if (!evaluateOnlyFirstLine) { 266 | counter += 1; 267 | } 268 | return { right, left }; 269 | }) 270 | .filter(Boolean); 271 | }; 272 | 273 | diffArray.forEach(({ added, removed, value }: diff.Change, index): void => { 274 | lineInformation = [ 275 | ...lineInformation, 276 | ...getLineInformation(value, index, added, removed), 277 | ]; 278 | }); 279 | 280 | return { 281 | lineInformation, 282 | diffLines, 283 | }; 284 | }; 285 | 286 | export { computeLineInformation }; 287 | -------------------------------------------------------------------------------- /src/expand.tsx: -------------------------------------------------------------------------------- 1 | export function Expand() { 2 | return ( 3 | 9 | expand 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/fold.tsx: -------------------------------------------------------------------------------- 1 | export function Fold() { 2 | return ( 3 | 9 | fold 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.yaml?raw" { 2 | const data: string; 3 | export default data; 4 | } 5 | 6 | declare module "*.rjs?raw" { 7 | const data: string; 8 | export default data; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from "classnames"; 2 | import * as React from "react"; 3 | import type { JSX, ReactElement } from "react"; 4 | 5 | import type { Change } from "diff"; 6 | import memoize from "memoize-one"; 7 | import { type Block, computeHiddenBlocks } from "./compute-hidden-blocks.js"; 8 | import { 9 | type DiffInformation, 10 | DiffMethod, 11 | DiffType, 12 | type LineInformation, 13 | computeLineInformation, 14 | } from "./compute-lines.js"; 15 | import { Expand } from "./expand.js"; 16 | import computeStyles, { 17 | type ReactDiffViewerStyles, 18 | type ReactDiffViewerStylesOverride, 19 | } from "./styles.js"; 20 | 21 | import { Fold } from "./fold.js"; 22 | 23 | type IntrinsicElements = JSX.IntrinsicElements; 24 | 25 | export enum LineNumberPrefix { 26 | LEFT = "L", 27 | RIGHT = "R", 28 | } 29 | 30 | export interface ReactDiffViewerProps { 31 | // Old value to compare. 32 | oldValue: string | Record; 33 | // New value to compare. 34 | newValue: string | Record; 35 | // Enable/Disable split view. 36 | splitView?: boolean; 37 | // Set line Offset 38 | linesOffset?: number; 39 | // Enable/Disable word diff. 40 | disableWordDiff?: boolean; 41 | // JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 42 | compareMethod?: DiffMethod | ((oldStr: string, newStr: string) => Change[]); 43 | // Number of unmodified lines surrounding each line diff. 44 | extraLinesSurroundingDiff?: number; 45 | // Show/hide line number. 46 | hideLineNumbers?: boolean; 47 | /** 48 | * Show the lines indicated here. Specified as L20 or R18 for respectively line 20 on the left or line 18 on the right. 49 | */ 50 | alwaysShowLines?: string[]; 51 | // Show only diff between the two values. 52 | showDiffOnly?: boolean; 53 | // Render prop to format final string before displaying them in the UI. 54 | renderContent?: (source: string) => ReactElement; 55 | // Render prop to format code fold message. 56 | codeFoldMessageRenderer?: ( 57 | totalFoldedLines: number, 58 | leftStartLineNumber: number, 59 | rightStartLineNumber: number, 60 | ) => ReactElement; 61 | // Event handler for line number click. 62 | onLineNumberClick?: ( 63 | lineId: string, 64 | event: React.MouseEvent, 65 | ) => void; 66 | // render gutter 67 | renderGutter?: (data: { 68 | lineNumber: number; 69 | type: DiffType; 70 | prefix: LineNumberPrefix; 71 | value: string | DiffInformation[]; 72 | additionalLineNumber: number; 73 | additionalPrefix: LineNumberPrefix; 74 | styles: ReactDiffViewerStyles; 75 | }) => ReactElement; 76 | // Array of line ids to highlight lines. 77 | highlightLines?: string[]; 78 | // Style overrides. 79 | styles?: ReactDiffViewerStylesOverride; 80 | // Use dark theme. 81 | useDarkTheme?: boolean; 82 | /** 83 | * Used to describe the thing being diffed 84 | */ 85 | summary?: string | ReactElement; 86 | // Title for left column 87 | leftTitle?: string | ReactElement; 88 | // Title for left column 89 | rightTitle?: string | ReactElement; 90 | // Nonce 91 | nonce?: string; 92 | } 93 | 94 | export interface ReactDiffViewerState { 95 | // Array holding the expanded code folding. 96 | expandedBlocks?: number[]; 97 | noSelect?: "left" | "right"; 98 | } 99 | 100 | class DiffViewer extends React.Component< 101 | ReactDiffViewerProps, 102 | ReactDiffViewerState 103 | > { 104 | private styles: ReactDiffViewerStyles; 105 | 106 | public static defaultProps: ReactDiffViewerProps = { 107 | oldValue: "", 108 | newValue: "", 109 | splitView: true, 110 | highlightLines: [], 111 | disableWordDiff: false, 112 | compareMethod: DiffMethod.CHARS, 113 | styles: {}, 114 | hideLineNumbers: false, 115 | extraLinesSurroundingDiff: 3, 116 | showDiffOnly: true, 117 | useDarkTheme: false, 118 | linesOffset: 0, 119 | nonce: "", 120 | }; 121 | 122 | public constructor(props: ReactDiffViewerProps) { 123 | super(props); 124 | 125 | this.state = { 126 | expandedBlocks: [], 127 | noSelect: undefined, 128 | }; 129 | } 130 | 131 | /** 132 | * Resets code block expand to the initial stage. Will be exposed to the parent component via 133 | * refs. 134 | */ 135 | public resetCodeBlocks = (): boolean => { 136 | if (this.state.expandedBlocks.length > 0) { 137 | this.setState({ 138 | expandedBlocks: [], 139 | }); 140 | return true; 141 | } 142 | return false; 143 | }; 144 | 145 | /** 146 | * Pushes the target expanded code block to the state. During the re-render, 147 | * this value is used to expand/fold unmodified code. 148 | */ 149 | private onBlockExpand = (id: number): void => { 150 | const prevState = this.state.expandedBlocks.slice(); 151 | prevState.push(id); 152 | 153 | this.setState({ 154 | expandedBlocks: prevState, 155 | }); 156 | }; 157 | 158 | /** 159 | * Computes final styles for the diff viewer. It combines the default styles with the user 160 | * supplied overrides. The computed styles are cached with performance in mind. 161 | * 162 | * @param styles User supplied style overrides. 163 | */ 164 | private computeStyles: ( 165 | styles: ReactDiffViewerStylesOverride, 166 | useDarkTheme: boolean, 167 | nonce: string, 168 | ) => ReactDiffViewerStyles = memoize(computeStyles); 169 | 170 | /** 171 | * Returns a function with clicked line number in the closure. Returns an no-op function when no 172 | * onLineNumberClick handler is supplied. 173 | * 174 | * @param id Line id of a line. 175 | */ 176 | private onLineNumberClickProxy = (id: string): any => { 177 | if (this.props.onLineNumberClick) { 178 | return (e: any): void => this.props.onLineNumberClick(id, e); 179 | } 180 | return (): void => {}; 181 | }; 182 | 183 | /** 184 | * Maps over the word diff and constructs the required React elements to show word diff. 185 | * 186 | * @param diffArray Word diff information derived from line information. 187 | * @param renderer Optional renderer to format diff words. Useful for syntax highlighting. 188 | */ 189 | private renderWordDiff = ( 190 | diffArray: DiffInformation[], 191 | renderer?: (chunk: string) => JSX.Element, 192 | ): ReactElement[] => { 193 | return diffArray.map((wordDiff, i): JSX.Element => { 194 | const content = renderer 195 | ? renderer(wordDiff.value as string) 196 | : (typeof wordDiff.value === 'string' 197 | ? wordDiff.value 198 | // If wordDiff.value is DiffInformation, we don't handle it, unclear why. See c0c99f5712. 199 | : undefined); 200 | 201 | return wordDiff.type === DiffType.ADDED ? ( 202 | 208 | {content} 209 | 210 | ) : wordDiff.type === DiffType.REMOVED ? ( 211 | 217 | {content} 218 | 219 | ) : ( 220 | 221 | {content} 222 | 223 | ); 224 | }); 225 | }; 226 | 227 | /** 228 | * Maps over the line diff and constructs the required react elements to show line diff. It calls 229 | * renderWordDiff when encountering word diff. This takes care of both inline and split view line 230 | * renders. 231 | * 232 | * @param lineNumber Line number of the current line. 233 | * @param type Type of diff of the current line. 234 | * @param prefix Unique id to prefix with the line numbers. 235 | * @param value Content of the line. It can be a string or a word diff array. 236 | * @param additionalLineNumber Additional line number to be shown. Useful for rendering inline 237 | * diff view. Right line number will be passed as additionalLineNumber. 238 | * @param additionalPrefix Similar to prefix but for additional line number. 239 | */ 240 | private renderLine = ( 241 | lineNumber: number, 242 | type: DiffType, 243 | prefix: LineNumberPrefix, 244 | value: string | DiffInformation[], 245 | additionalLineNumber?: number, 246 | additionalPrefix?: LineNumberPrefix, 247 | ): ReactElement => { 248 | const lineNumberTemplate = `${prefix}-${lineNumber}`; 249 | const additionalLineNumberTemplate = `${additionalPrefix}-${additionalLineNumber}`; 250 | const highlightLine = 251 | this.props.highlightLines.includes(lineNumberTemplate) || 252 | this.props.highlightLines.includes(additionalLineNumberTemplate); 253 | const added = type === DiffType.ADDED; 254 | const removed = type === DiffType.REMOVED; 255 | const changed = type === DiffType.CHANGED; 256 | let content; 257 | const hasWordDiff = Array.isArray(value); 258 | if (hasWordDiff) { 259 | content = this.renderWordDiff(value, this.props.renderContent); 260 | } else if (this.props.renderContent) { 261 | content = this.props.renderContent(value); 262 | } else { 263 | content = value; 264 | } 265 | 266 | let ElementType: keyof IntrinsicElements = "div"; 267 | if (added && !hasWordDiff) { 268 | ElementType = "ins"; 269 | } else if (removed && !hasWordDiff) { 270 | ElementType = "del"; 271 | } 272 | 273 | return ( 274 | <> 275 | {!this.props.hideLineNumbers && ( 276 | 288 |
{lineNumber}
289 | 290 | )} 291 | {!this.props.splitView && !this.props.hideLineNumbers && ( 292 | 305 |
{additionalLineNumber}
306 | 307 | )} 308 | {this.props.renderGutter 309 | ? this.props.renderGutter({ 310 | lineNumber, 311 | type, 312 | prefix, 313 | value, 314 | additionalLineNumber, 315 | additionalPrefix, 316 | styles: this.styles, 317 | }) 318 | : null} 319 | 328 |
329 |             {added && "+"}
330 |             {removed && "-"}
331 |           
332 | 333 | { 344 | const elements = document.getElementsByClassName( 345 | prefix === LineNumberPrefix.LEFT ? "right" : "left", 346 | ); 347 | for (let i = 0; i < elements.length; i++) { 348 | const element = elements.item(i); 349 | element.classList.add(this.styles.noSelect); 350 | } 351 | }} 352 | title={ 353 | added && !hasWordDiff 354 | ? "Added line" 355 | : removed && !hasWordDiff 356 | ? "Removed line" 357 | : undefined 358 | } 359 | > 360 | 361 | {content} 362 | 363 | 364 | 365 | ); 366 | }; 367 | 368 | /** 369 | * Generates lines for split view. 370 | * 371 | * @param obj Line diff information. 372 | * @param obj.left Life diff information for the left pane of the split view. 373 | * @param obj.right Life diff information for the right pane of the split view. 374 | * @param index React key for the lines. 375 | */ 376 | private renderSplitView = ( 377 | { left, right }: LineInformation, 378 | index: number, 379 | ): ReactElement => { 380 | return ( 381 | 382 | {this.renderLine( 383 | left.lineNumber, 384 | left.type, 385 | LineNumberPrefix.LEFT, 386 | left.value, 387 | )} 388 | {this.renderLine( 389 | right.lineNumber, 390 | right.type, 391 | LineNumberPrefix.RIGHT, 392 | right.value, 393 | )} 394 | 395 | ); 396 | }; 397 | 398 | /** 399 | * Generates lines for inline view. 400 | * 401 | * @param obj Line diff information. 402 | * @param obj.left Life diff information for the added section of the inline view. 403 | * @param obj.right Life diff information for the removed section of the inline view. 404 | * @param index React key for the lines. 405 | */ 406 | public renderInlineView = ( 407 | { left, right }: LineInformation, 408 | index: number, 409 | ): ReactElement => { 410 | let content; 411 | if (left.type === DiffType.REMOVED && right.type === DiffType.ADDED) { 412 | return ( 413 | 414 | 415 | {this.renderLine( 416 | left.lineNumber, 417 | left.type, 418 | LineNumberPrefix.LEFT, 419 | left.value, 420 | null, 421 | )} 422 | 423 | 424 | {this.renderLine( 425 | null, 426 | right.type, 427 | LineNumberPrefix.RIGHT, 428 | right.value, 429 | right.lineNumber, 430 | LineNumberPrefix.RIGHT, 431 | )} 432 | 433 | 434 | ); 435 | } 436 | if (left.type === DiffType.REMOVED) { 437 | content = this.renderLine( 438 | left.lineNumber, 439 | left.type, 440 | LineNumberPrefix.LEFT, 441 | left.value, 442 | null, 443 | ); 444 | } 445 | if (left.type === DiffType.DEFAULT) { 446 | content = this.renderLine( 447 | left.lineNumber, 448 | left.type, 449 | LineNumberPrefix.LEFT, 450 | left.value, 451 | right.lineNumber, 452 | LineNumberPrefix.RIGHT, 453 | ); 454 | } 455 | if (right.type === DiffType.ADDED) { 456 | content = this.renderLine( 457 | null, 458 | right.type, 459 | LineNumberPrefix.RIGHT, 460 | right.value, 461 | right.lineNumber, 462 | ); 463 | } 464 | 465 | return ( 466 | 467 | {content} 468 | 469 | ); 470 | }; 471 | 472 | /** 473 | * Returns a function with clicked block number in the closure. 474 | * 475 | * @param id Cold fold block id. 476 | */ 477 | private onBlockClickProxy = 478 | (id: number): (() => void) => 479 | (): void => 480 | this.onBlockExpand(id); 481 | 482 | /** 483 | * Generates cold fold block. It also uses the custom message renderer when available to show 484 | * cold fold messages. 485 | * 486 | * @param num Number of skipped lines between two blocks. 487 | * @param blockNumber Code fold block id. 488 | * @param leftBlockLineNumber First left line number after the current code fold block. 489 | * @param rightBlockLineNumber First right line number after the current code fold block. 490 | */ 491 | private renderSkippedLineIndicator = ( 492 | num: number, 493 | blockNumber: number, 494 | leftBlockLineNumber: number, 495 | rightBlockLineNumber: number, 496 | ): ReactElement => { 497 | const { hideLineNumbers, splitView } = this.props; 498 | const message = this.props.codeFoldMessageRenderer ? ( 499 | this.props.codeFoldMessageRenderer( 500 | num, 501 | leftBlockLineNumber, 502 | rightBlockLineNumber, 503 | ) 504 | ) : ( 505 | 506 | Expand {num} lines ... 507 | 508 | ); 509 | const content = ( 510 | 511 | 519 | 520 | ); 521 | const isUnifiedViewWithoutLineNumbers = !splitView && !hideLineNumbers; 522 | return ( 523 | 527 | {!hideLineNumbers && } 528 | {this.props.renderGutter ? ( 529 | 530 | ) : null} 531 | 536 | 537 | {/* Swap columns only for unified view without line numbers */} 538 | {isUnifiedViewWithoutLineNumbers ? ( 539 | 540 | 541 | {content} 542 | 543 | ) : ( 544 | 545 | {content} 546 | {this.props.renderGutter ? : null} 547 | 548 | 549 | {!hideLineNumbers ? : null} 550 | 551 | )} 552 | 553 | ); 554 | }; 555 | 556 | /** 557 | * Generates the entire diff view. 558 | */ 559 | private renderDiff = (): { 560 | diffNodes: ReactElement[]; 561 | lineInformation: LineInformation[]; 562 | blocks: Block[]; 563 | } => { 564 | const { 565 | oldValue, 566 | newValue, 567 | splitView, 568 | disableWordDiff, 569 | compareMethod, 570 | linesOffset, 571 | } = this.props; 572 | const { lineInformation, diffLines } = computeLineInformation( 573 | oldValue, 574 | newValue, 575 | disableWordDiff, 576 | compareMethod, 577 | linesOffset, 578 | this.props.alwaysShowLines, 579 | ); 580 | 581 | const extraLines = 582 | this.props.extraLinesSurroundingDiff < 0 583 | ? 0 584 | : Math.round(this.props.extraLinesSurroundingDiff); 585 | 586 | const { lineBlocks, blocks } = computeHiddenBlocks( 587 | lineInformation, 588 | diffLines, 589 | extraLines, 590 | ); 591 | 592 | const diffNodes = lineInformation.map( 593 | (line: LineInformation, lineIndex: number) => { 594 | if (this.props.showDiffOnly) { 595 | const blockIndex = lineBlocks[lineIndex]; 596 | 597 | if (blockIndex !== undefined) { 598 | const lastLineOfBlock = blocks[blockIndex].endLine === lineIndex; 599 | if ( 600 | !this.state.expandedBlocks.includes(blockIndex) && 601 | lastLineOfBlock 602 | ) { 603 | return ( 604 | 605 | {this.renderSkippedLineIndicator( 606 | blocks[blockIndex].lines, 607 | blockIndex, 608 | line.left.lineNumber, 609 | line.right.lineNumber, 610 | )} 611 | 612 | ); 613 | } 614 | if (!this.state.expandedBlocks.includes(blockIndex)) { 615 | return null; 616 | } 617 | } 618 | } 619 | 620 | return splitView 621 | ? this.renderSplitView(line, lineIndex) 622 | : this.renderInlineView(line, lineIndex); 623 | }, 624 | ); 625 | return { 626 | diffNodes, 627 | blocks, 628 | lineInformation, 629 | }; 630 | }; 631 | 632 | public render = (): ReactElement => { 633 | const { 634 | oldValue, 635 | newValue, 636 | useDarkTheme, 637 | leftTitle, 638 | rightTitle, 639 | splitView, 640 | compareMethod, 641 | hideLineNumbers, 642 | nonce, 643 | } = this.props; 644 | 645 | if ( 646 | typeof compareMethod === "string" && 647 | compareMethod !== DiffMethod.JSON 648 | ) { 649 | if (typeof oldValue !== "string" || typeof newValue !== "string") { 650 | throw Error('"oldValue" and "newValue" should be strings'); 651 | } 652 | } 653 | 654 | this.styles = this.computeStyles(this.props.styles, useDarkTheme, nonce); 655 | const nodes = this.renderDiff(); 656 | 657 | let colSpanOnSplitView = 3; 658 | let colSpanOnInlineView = 4; 659 | 660 | if (hideLineNumbers) { 661 | colSpanOnSplitView -= 1; 662 | colSpanOnInlineView -= 1; 663 | } 664 | 665 | if (this.props.renderGutter) { 666 | colSpanOnSplitView += 1; 667 | colSpanOnInlineView += 1; 668 | } 669 | 670 | let deletions = 0; 671 | let additions = 0; 672 | for (const l of nodes.lineInformation) { 673 | if (l.left.type === DiffType.ADDED) { 674 | additions++; 675 | } 676 | if (l.right.type === DiffType.ADDED) { 677 | additions++; 678 | } 679 | if (l.left.type === DiffType.REMOVED) { 680 | deletions++; 681 | } 682 | if (l.right.type === DiffType.REMOVED) { 683 | deletions++; 684 | } 685 | } 686 | const totalChanges = deletions + additions; 687 | 688 | const percentageAddition = Math.round((additions / totalChanges) * 100); 689 | const blocks: ReactElement[] = []; 690 | for (let i = 0; i < 5; i++) { 691 | if (percentageAddition > i * 20) { 692 | blocks.push( 693 | , 697 | ); 698 | } else { 699 | blocks.push( 700 | , 704 | ); 705 | } 706 | } 707 | const allExpanded = 708 | this.state.expandedBlocks.length === nodes.blocks.length; 709 | 710 | return ( 711 |
712 |
713 | {" "} 726 | {totalChanges} 727 |
{blocks}
728 | {this.props.summary ? {this.props.summary} : null} 729 |
730 | { 735 | const elements = document.getElementsByClassName("right"); 736 | for (let i = 0; i < elements.length; i++) { 737 | const element = elements.item(i); 738 | element.classList.remove(this.styles.noSelect); 739 | } 740 | const elementsLeft = document.getElementsByClassName("left"); 741 | for (let i = 0; i < elementsLeft.length; i++) { 742 | const element = elementsLeft.item(i); 743 | element.classList.remove(this.styles.noSelect); 744 | } 745 | }} 746 | > 747 | 748 | 749 | {!this.props.hideLineNumbers ? 765 | {leftTitle || rightTitle ? ( 766 | 767 | 775 | {splitView ? ( 776 | 786 | ) : null} 787 | 788 | ) : null} 789 | {nodes.diffNodes} 790 | 791 |
: null} 750 | {!splitView && !this.props.hideLineNumbers ? ( 751 | 752 | ) : null} 753 | {this.props.renderGutter ? : null} 754 | 755 | 756 | {splitView ? ( 757 | <> 758 | {!this.props.hideLineNumbers ? : null} 759 | {this.props.renderGutter ? : null} 760 | 761 | 762 | 763 | ) : null} 764 |
771 | {leftTitle ? ( 772 |
{leftTitle}
773 | ) : null} 774 |
780 | {rightTitle ? ( 781 |
782 |                         {rightTitle}
783 |                       
784 | ) : null} 785 |
792 |
793 | ); 794 | }; 795 | } 796 | 797 | export default DiffViewer; 798 | export { DiffMethod }; 799 | export type { ReactDiffViewerStylesOverride }; 800 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import createEmotion from "@emotion/css/create-instance"; 2 | import type { Interpolation } from "@emotion/react"; 3 | 4 | export interface ReactDiffViewerStyles { 5 | diffContainer?: string; 6 | diffRemoved?: string; 7 | diffAdded?: string; 8 | diffChanged?: string; 9 | line?: string; 10 | highlightedGutter?: string; 11 | contentText?: string; 12 | lineContent?: string; 13 | gutter?: string; 14 | highlightedLine?: string; 15 | lineNumber?: string; 16 | marker?: string; 17 | wordDiff?: string; 18 | wordAdded?: string; 19 | wordRemoved?: string; 20 | codeFoldGutter?: string; 21 | codeFoldExpandButton?: string; 22 | summary?: string; 23 | codeFoldContentContainer?: string; 24 | emptyGutter?: string; 25 | emptyLine?: string; 26 | codeFold?: string; 27 | titleBlock?: string; 28 | content?: string; 29 | column?: string; 30 | noSelect?: string; 31 | splitView?: string; 32 | allExpandButton?: string; 33 | [key: string]: string | undefined; 34 | } 35 | 36 | export interface ReactDiffViewerStylesVariables { 37 | diffViewerBackground?: string; 38 | diffViewerTitleBackground?: string; 39 | diffViewerColor?: string; 40 | diffViewerTitleColor?: string; 41 | diffViewerTitleBorderColor?: string; 42 | addedBackground?: string; 43 | addedColor?: string; 44 | removedBackground?: string; 45 | removedColor?: string; 46 | changedBackground?: string; 47 | wordAddedBackground?: string; 48 | wordRemovedBackground?: string; 49 | addedGutterBackground?: string; 50 | removedGutterBackground?: string; 51 | gutterBackground?: string; 52 | gutterBackgroundDark?: string; 53 | highlightBackground?: string; 54 | highlightGutterBackground?: string; 55 | codeFoldGutterBackground?: string; 56 | codeFoldBackground?: string; 57 | emptyLineBackground?: string; 58 | gutterColor?: string; 59 | addedGutterColor?: string; 60 | removedGutterColor?: string; 61 | codeFoldContentColor?: string; 62 | } 63 | 64 | export interface ReactDiffViewerStylesOverride { 65 | variables?: { 66 | dark?: ReactDiffViewerStylesVariables; 67 | light?: ReactDiffViewerStylesVariables; 68 | }; 69 | diffContainer?: Interpolation; 70 | diffRemoved?: Interpolation; 71 | diffAdded?: Interpolation; 72 | diffChanged?: Interpolation; 73 | marker?: Interpolation; 74 | emptyGutter?: Interpolation; 75 | highlightedLine?: Interpolation; 76 | lineNumber?: Interpolation; 77 | highlightedGutter?: Interpolation; 78 | contentText?: Interpolation; 79 | gutter?: Interpolation; 80 | line?: Interpolation; 81 | wordDiff?: Interpolation; 82 | wordAdded?: Interpolation; 83 | wordRemoved?: Interpolation; 84 | codeFoldGutter?: Interpolation; 85 | codeFoldExpandButton?: Interpolation; 86 | codeFoldContentContainer?: Interpolation; 87 | codeFold?: Interpolation; 88 | emptyLine?: Interpolation; 89 | content?: Interpolation; 90 | noSelect?: Interpolation; 91 | column?: Interpolation; 92 | titleBlock?: Interpolation; 93 | splitView?: Interpolation; 94 | allExpandButton?: Interpolation; 95 | } 96 | 97 | export default ( 98 | styleOverride: ReactDiffViewerStylesOverride, 99 | useDarkTheme = false, 100 | nonce = "", 101 | ): ReactDiffViewerStyles => { 102 | const { variables: overrideVariables = {}, ...styles } = styleOverride; 103 | 104 | const themeVariables = { 105 | light: { 106 | ...{ 107 | diffViewerBackground: "#fff", 108 | diffViewerColor: "#212529", 109 | addedBackground: "#e6ffed", 110 | addedColor: "#24292e", 111 | removedBackground: "#ffeef0", 112 | removedColor: "#24292e", 113 | changedBackground: "#fffbdd", 114 | wordAddedBackground: "#acf2bd", 115 | wordRemovedBackground: "#fdb8c0", 116 | addedGutterBackground: "#cdffd8", 117 | removedGutterBackground: "#ffdce0", 118 | gutterBackground: "#f7f7f7", 119 | gutterBackgroundDark: "#f3f1f1", 120 | highlightBackground: "#fffbdd", 121 | highlightGutterBackground: "#fff5b1", 122 | codeFoldGutterBackground: "#dbedff", 123 | codeFoldBackground: "#f1f8ff", 124 | emptyLineBackground: "#fafbfc", 125 | gutterColor: "#212529", 126 | addedGutterColor: "#212529", 127 | removedGutterColor: "#212529", 128 | codeFoldContentColor: "#212529", 129 | diffViewerTitleBackground: "#fafbfc", 130 | diffViewerTitleColor: "#212529", 131 | diffViewerTitleBorderColor: "#eee", 132 | }, 133 | ...(overrideVariables.light || {}), 134 | }, 135 | dark: { 136 | ...{ 137 | diffViewerBackground: "#2e303c", 138 | diffViewerColor: "#FFF", 139 | addedBackground: "#044B53", 140 | addedColor: "white", 141 | removedBackground: "#632F34", 142 | removedColor: "white", 143 | changedBackground: "#3e302c", 144 | wordAddedBackground: "#055d67", 145 | wordRemovedBackground: "#7d383f", 146 | addedGutterBackground: "#034148", 147 | removedGutterBackground: "#632b30", 148 | gutterBackground: "#2c2f3a", 149 | gutterBackgroundDark: "#262933", 150 | highlightBackground: "#2a3967", 151 | highlightGutterBackground: "#2d4077", 152 | codeFoldGutterBackground: "#262831", 153 | codeFoldBackground: "#262831", 154 | emptyLineBackground: "#363946", 155 | gutterColor: "#666c87", 156 | addedGutterColor: "#8c8c8c", 157 | removedGutterColor: "#8c8c8c", 158 | codeFoldContentColor: "#656a8b", 159 | diffViewerTitleBackground: "#2f323e", 160 | diffViewerTitleColor: "#757a9b", 161 | diffViewerTitleBorderColor: "#353846", 162 | }, 163 | ...(overrideVariables.dark || {}), 164 | }, 165 | }; 166 | 167 | const variables = useDarkTheme ? themeVariables.dark : themeVariables.light; 168 | 169 | const { css, cx } = createEmotion({ key: "react-diff", nonce }); 170 | 171 | const content = css({ 172 | width: "auto", 173 | label: "content", 174 | }); 175 | 176 | const splitView = css({ 177 | label: "split-view", 178 | }); 179 | 180 | const summary = css({ 181 | background: variables.diffViewerTitleBackground, 182 | color: variables.diffViewerTitleColor, 183 | padding: "0.5em 1em", 184 | display: "flex", 185 | alignItems: "center", 186 | gap: "0.5em", 187 | fontFamily: "monospace", 188 | fill: variables.diffViewerTitleColor, 189 | }); 190 | 191 | const diffContainer = css({ 192 | width: "100%", 193 | minWidth: "1000px", 194 | overflowX: "auto", 195 | tableLayout: "fixed", 196 | background: variables.diffViewerBackground, 197 | pre: { 198 | margin: 0, 199 | whiteSpace: "pre-wrap", 200 | lineHeight: "1.6em", 201 | width: "fit-content", 202 | }, 203 | label: "diff-container", 204 | borderCollapse: "collapse", 205 | }); 206 | 207 | const lineContent = css({ 208 | overflow: "hidden", 209 | width: "100%", 210 | }); 211 | 212 | const contentText = css({ 213 | color: variables.diffViewerColor, 214 | whiteSpace: "pre-wrap", 215 | fontFamily: "monospace", 216 | lineBreak: "anywhere", 217 | textDecoration: "none", 218 | label: "content-text", 219 | }); 220 | 221 | const unselectable = css({ 222 | userSelect: "none", 223 | label: "unselectable", 224 | }); 225 | 226 | const allExpandButton = css({ 227 | background: "transparent", 228 | border: "none", 229 | cursor: "pointer", 230 | display: "flex", 231 | alignItems: "center", 232 | justifyContent: "center", 233 | margin: 0, 234 | label: "all-expand-button", 235 | ":hover": { 236 | fill: variables.addedGutterColor, 237 | }, 238 | ":focus": { 239 | outline: `1px ${variables.addedGutterColor} solid`, 240 | }, 241 | }); 242 | 243 | const titleBlock = css({ 244 | background: variables.diffViewerTitleBackground, 245 | padding: "0.5em", 246 | lineHeight: "1.4em", 247 | height: "2.4em", 248 | overflow: "hidden", 249 | width: "50%", 250 | borderBottom: `1px solid ${variables.diffViewerTitleBorderColor}`, 251 | label: "title-block", 252 | ":last-child": { 253 | borderLeft: `1px solid ${variables.diffViewerTitleBorderColor}`, 254 | }, 255 | [`.${contentText}`]: { 256 | color: variables.diffViewerTitleColor, 257 | }, 258 | }); 259 | 260 | const lineNumber = css({ 261 | color: variables.gutterColor, 262 | label: "line-number", 263 | }); 264 | 265 | const diffRemoved = css({ 266 | background: variables.removedBackground, 267 | color: variables.removedColor, 268 | pre: { 269 | color: variables.removedColor, 270 | }, 271 | [`.${lineNumber}`]: { 272 | color: variables.removedGutterColor, 273 | }, 274 | label: "diff-removed", 275 | }); 276 | 277 | const diffAdded = css({ 278 | background: variables.addedBackground, 279 | color: variables.addedColor, 280 | pre: { 281 | color: variables.addedColor, 282 | }, 283 | [`.${lineNumber}`]: { 284 | color: variables.addedGutterColor, 285 | }, 286 | label: "diff-added", 287 | }); 288 | 289 | const diffChanged = css({ 290 | background: variables.changedBackground, 291 | [`.${lineNumber}`]: { 292 | color: variables.gutterColor, 293 | }, 294 | label: "diff-changed", 295 | }); 296 | 297 | const wordDiff = css({ 298 | padding: 2, 299 | display: "inline-flex", 300 | borderRadius: 4, 301 | wordBreak: "break-all", 302 | label: "word-diff", 303 | }); 304 | 305 | const wordAdded = css({ 306 | background: variables.wordAddedBackground, 307 | textDecoration: "none", 308 | label: "word-added", 309 | }); 310 | 311 | const wordRemoved = css({ 312 | background: variables.wordRemovedBackground, 313 | textDecoration: "none", 314 | label: "word-removed", 315 | }); 316 | 317 | const codeFoldGutter = css({ 318 | backgroundColor: variables.codeFoldGutterBackground, 319 | label: "code-fold-gutter", 320 | minWidth: "50px", 321 | width: "50px", 322 | }); 323 | 324 | const codeFoldContentContainer = css({ 325 | padding: "", 326 | }); 327 | 328 | const codeFoldExpandButton = css({ 329 | background: variables.codeFoldBackground, 330 | cursor: "pointer", 331 | display: "inline", 332 | margin: 0, 333 | border: "none", 334 | label: "code-fold-expand-button", 335 | }); 336 | 337 | const codeFoldContent = css({ 338 | color: variables.codeFoldContentColor, 339 | fontFamily: "monospace", 340 | label: "code-fold-content", 341 | }); 342 | 343 | const block = css({ 344 | display: "block", 345 | width: "10px", 346 | height: "10px", 347 | backgroundColor: "#ddd", 348 | borderWidth: "1px", 349 | borderStyle: "solid", 350 | borderColor: variables.diffViewerTitleBorderColor, 351 | }); 352 | 353 | const blockAddition = css({ 354 | backgroundColor: variables.wordAddedBackground, 355 | }); 356 | 357 | const blockDeletion = css({ 358 | backgroundColor: variables.wordRemovedBackground, 359 | }); 360 | 361 | const codeFold = css({ 362 | backgroundColor: variables.codeFoldBackground, 363 | height: 40, 364 | fontSize: 14, 365 | alignItems: "center", 366 | userSelect: "none", 367 | fontWeight: 700, 368 | label: "code-fold", 369 | a: { 370 | textDecoration: "underline !important", 371 | cursor: "pointer", 372 | pre: { 373 | display: "inline", 374 | }, 375 | }, 376 | }); 377 | 378 | const emptyLine = css({ 379 | backgroundColor: variables.emptyLineBackground, 380 | label: "empty-line", 381 | }); 382 | 383 | const marker = css({ 384 | width: 28, 385 | paddingLeft: 10, 386 | paddingRight: 10, 387 | userSelect: "none", 388 | label: "marker", 389 | [`&.${diffAdded}`]: { 390 | pre: { 391 | color: variables.addedColor, 392 | }, 393 | }, 394 | [`&.${diffRemoved}`]: { 395 | pre: { 396 | color: variables.removedColor, 397 | }, 398 | }, 399 | }); 400 | 401 | const highlightedLine = css({ 402 | background: variables.highlightBackground, 403 | label: "highlighted-line", 404 | [`.${wordAdded}, .${wordRemoved}`]: { 405 | backgroundColor: "initial", 406 | }, 407 | }); 408 | 409 | const highlightedGutter = css({ 410 | label: "highlighted-gutter", 411 | }); 412 | 413 | const gutter = css({ 414 | userSelect: "none", 415 | minWidth: 50, 416 | width: "50px", 417 | padding: "0 10px", 418 | whiteSpace: "nowrap", 419 | label: "gutter", 420 | textAlign: "right", 421 | background: variables.gutterBackground, 422 | "&:hover": { 423 | cursor: "pointer", 424 | background: variables.gutterBackgroundDark, 425 | pre: { 426 | opacity: 1, 427 | }, 428 | }, 429 | pre: { 430 | opacity: 0.5, 431 | }, 432 | [`&.${diffAdded}`]: { 433 | background: variables.addedGutterBackground, 434 | }, 435 | [`&.${diffRemoved}`]: { 436 | background: variables.removedGutterBackground, 437 | }, 438 | [`&.${highlightedGutter}`]: { 439 | background: variables.highlightGutterBackground, 440 | "&:hover": { 441 | background: variables.highlightGutterBackground, 442 | }, 443 | }, 444 | }); 445 | 446 | const emptyGutter = css({ 447 | "&:hover": { 448 | background: variables.gutterBackground, 449 | cursor: "initial", 450 | }, 451 | label: "empty-gutter", 452 | }); 453 | 454 | const line = css({ 455 | verticalAlign: "baseline", 456 | label: "line", 457 | textDecoration: "none", 458 | }); 459 | 460 | const column = css({}); 461 | 462 | const defaultStyles: any = { 463 | diffContainer, 464 | diffRemoved, 465 | diffAdded, 466 | diffChanged, 467 | splitView, 468 | marker, 469 | highlightedGutter, 470 | highlightedLine, 471 | gutter, 472 | line, 473 | lineContent, 474 | wordDiff, 475 | wordAdded, 476 | summary, 477 | block, 478 | blockAddition, 479 | blockDeletion, 480 | wordRemoved, 481 | noSelect: unselectable, 482 | codeFoldGutter, 483 | codeFoldExpandButton, 484 | codeFoldContentContainer, 485 | codeFold, 486 | emptyGutter, 487 | emptyLine, 488 | lineNumber, 489 | contentText, 490 | content, 491 | column, 492 | codeFoldContent, 493 | titleBlock, 494 | allExpandButton, 495 | }; 496 | 497 | const computerOverrideStyles: ReactDiffViewerStyles = Object.keys( 498 | styles, 499 | ).reduce( 500 | (acc, key): ReactDiffViewerStyles => ({ 501 | ...acc, 502 | ...{ 503 | [key]: css((styles as any)[key]), 504 | }, 505 | }), 506 | {}, 507 | ); 508 | 509 | return Object.keys(defaultStyles).reduce( 510 | (acc, key): ReactDiffViewerStyles => ({ 511 | ...acc, 512 | ...{ 513 | [key]: computerOverrideStyles[key] 514 | ? cx(defaultStyles[key], computerOverrideStyles[key]) 515 | : defaultStyles[key], 516 | }, 517 | }), 518 | {}, 519 | ); 520 | }; 521 | -------------------------------------------------------------------------------- /test/compute-lines.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { DiffMethod, computeLineInformation } from "../src/compute-lines"; 3 | 4 | describe("Testing compute lines utils", (): void => { 5 | it("It should not avoid trailing spaces", (): void => { 6 | const oldCode = `test 7 | 8 | 9 | `; 10 | const newCode = `test 11 | 12 | `; 13 | 14 | expect(computeLineInformation(oldCode, newCode)).toMatchObject({ 15 | lineInformation: [ 16 | { 17 | left: { 18 | lineNumber: 1, 19 | type: 0, 20 | value: "test", 21 | }, 22 | right: { 23 | lineNumber: 1, 24 | type: 0, 25 | value: "test", 26 | }, 27 | }, 28 | { 29 | left: { 30 | lineNumber: 2, 31 | type: 0, 32 | value: "", 33 | }, 34 | right: { 35 | lineNumber: 2, 36 | type: 0, 37 | value: "", 38 | }, 39 | }, 40 | { 41 | left: { 42 | lineNumber: 3, 43 | type: 2, 44 | value: " ", 45 | }, 46 | right: {}, 47 | }, 48 | { 49 | left: { 50 | lineNumber: 4, 51 | type: 0, 52 | value: " ", 53 | }, 54 | right: { 55 | lineNumber: 3, 56 | type: 0, 57 | value: " ", 58 | }, 59 | }, 60 | ], 61 | diffLines: [2], 62 | }); 63 | }); 64 | 65 | it("Should identify line addition", (): void => { 66 | const oldCode = "test"; 67 | const newCode = `test 68 | newLine`; 69 | 70 | expect(computeLineInformation(oldCode, newCode, true)).toMatchObject({ 71 | lineInformation: [ 72 | { 73 | right: { 74 | lineNumber: 1, 75 | type: 0, 76 | value: "test", 77 | }, 78 | left: { 79 | lineNumber: 1, 80 | type: 0, 81 | value: "test", 82 | }, 83 | }, 84 | { 85 | right: { 86 | lineNumber: 2, 87 | type: 1, 88 | value: " newLine", 89 | }, 90 | left: {}, 91 | }, 92 | ], 93 | diffLines: [1], 94 | }); 95 | }); 96 | 97 | it("Should identify line deletion", (): void => { 98 | const oldCode = `test 99 | oldLine`; 100 | const newCode = "test"; 101 | 102 | expect(computeLineInformation(oldCode, newCode)).toMatchObject({ 103 | lineInformation: [ 104 | { 105 | right: { 106 | lineNumber: 1, 107 | type: 0, 108 | value: "test", 109 | }, 110 | left: { 111 | lineNumber: 1, 112 | type: 0, 113 | value: "test", 114 | }, 115 | }, 116 | { 117 | right: {}, 118 | left: { 119 | lineNumber: 2, 120 | type: 2, 121 | value: " oldLine", 122 | }, 123 | }, 124 | ], 125 | diffLines: [1], 126 | }); 127 | }); 128 | 129 | it("Should identify line modification", (): void => { 130 | const oldCode = `test 131 | oldLine`; 132 | const newCode = `test 133 | newLine`; 134 | 135 | expect(computeLineInformation(oldCode, newCode, true)).toMatchObject({ 136 | lineInformation: [ 137 | { 138 | right: { 139 | lineNumber: 1, 140 | type: 0, 141 | value: "test", 142 | }, 143 | left: { 144 | lineNumber: 1, 145 | type: 0, 146 | value: "test", 147 | }, 148 | }, 149 | { 150 | right: { 151 | lineNumber: 2, 152 | type: 1, 153 | value: " newLine", 154 | }, 155 | left: { 156 | lineNumber: 2, 157 | type: 2, 158 | value: " oldLine", 159 | }, 160 | }, 161 | ], 162 | diffLines: [1], 163 | }); 164 | }); 165 | 166 | it("Should identify word diff", (): void => { 167 | const oldCode = `test 168 | oldLine`; 169 | const newCode = `test 170 | newLine`; 171 | 172 | expect(computeLineInformation(oldCode, newCode)).toMatchObject({ 173 | lineInformation: [ 174 | { 175 | right: { 176 | lineNumber: 1, 177 | type: 0, 178 | value: "test", 179 | }, 180 | left: { 181 | lineNumber: 1, 182 | type: 0, 183 | value: "test", 184 | }, 185 | }, 186 | { 187 | right: { 188 | lineNumber: 2, 189 | type: 1, 190 | value: [ 191 | { 192 | type: 0, 193 | value: " ", 194 | }, 195 | { 196 | type: 1, 197 | value: "new", 198 | }, 199 | { 200 | type: 0, 201 | value: "Line", 202 | }, 203 | ], 204 | }, 205 | left: { 206 | lineNumber: 2, 207 | type: 2, 208 | value: [ 209 | { 210 | type: 0, 211 | value: " ", 212 | }, 213 | { 214 | type: 2, 215 | value: "old", 216 | }, 217 | { 218 | type: 0, 219 | value: "Line", 220 | }, 221 | ], 222 | }, 223 | }, 224 | ], 225 | diffLines: [1], 226 | }); 227 | }); 228 | 229 | it('Should call "diffChars" jsDiff method when compareMethod is not provided', (): void => { 230 | const oldCode = "Hello World"; 231 | const newCode = `My Updated Name 232 | Also this info`; 233 | 234 | expect(computeLineInformation(oldCode, newCode)).toMatchObject({ 235 | lineInformation: [ 236 | { 237 | right: { 238 | lineNumber: 1, 239 | type: 1, 240 | value: [ 241 | { 242 | type: 1, 243 | value: "My Updat", 244 | }, 245 | { 246 | type: 0, 247 | value: "e", 248 | }, 249 | { 250 | type: 1, 251 | value: "d", 252 | }, 253 | { 254 | type: 0, 255 | value: " ", 256 | }, 257 | { 258 | type: 1, 259 | value: "Name", 260 | }, 261 | ], 262 | }, 263 | left: { 264 | lineNumber: 1, 265 | type: 2, 266 | value: [ 267 | { 268 | type: 2, 269 | value: "H", 270 | }, 271 | { 272 | type: 0, 273 | value: "e", 274 | }, 275 | { 276 | type: 2, 277 | value: "llo", 278 | }, 279 | { 280 | type: 0, 281 | value: " ", 282 | }, 283 | { 284 | type: 2, 285 | value: "World", 286 | }, 287 | ], 288 | }, 289 | }, 290 | { 291 | right: { 292 | lineNumber: 2, 293 | type: 1, 294 | value: "Also this info", 295 | }, 296 | left: {}, 297 | }, 298 | ], 299 | diffLines: [0, 1], 300 | }); 301 | }); 302 | 303 | it('Should call "diffWords" jsDiff method when a compareMethod IS provided', (): void => { 304 | const oldCode = "Hello World"; 305 | const newCode = `My Updated Name 306 | Also this info`; 307 | 308 | expect( 309 | computeLineInformation(oldCode, newCode, false, DiffMethod.WORDS), 310 | ).toMatchObject({ 311 | lineInformation: [ 312 | { 313 | right: { 314 | lineNumber: 1, 315 | type: 1, 316 | value: [ 317 | { 318 | type: 1, 319 | value: "My", 320 | }, 321 | { 322 | type: 0, 323 | value: " ", 324 | }, 325 | { 326 | type: 1, 327 | value: "Updated Name", 328 | }, 329 | ], 330 | }, 331 | left: { 332 | lineNumber: 1, 333 | type: 2, 334 | value: [ 335 | { 336 | type: 2, 337 | value: "Hello", 338 | }, 339 | { 340 | type: 0, 341 | value: " ", 342 | }, 343 | { 344 | type: 2, 345 | value: "World", 346 | }, 347 | ], 348 | }, 349 | }, 350 | { 351 | right: { 352 | lineNumber: 2, 353 | type: 1, 354 | value: "Also this info", 355 | }, 356 | left: {}, 357 | }, 358 | ], 359 | diffLines: [0, 1], 360 | }); 361 | }); 362 | 363 | it("Should not call jsDiff method and not diff text when disableWordDiff is true", (): void => { 364 | const oldCode = "Hello World"; 365 | const newCode = `My Updated Name 366 | Also this info`; 367 | 368 | expect(computeLineInformation(oldCode, newCode, true)).toMatchObject({ 369 | lineInformation: [ 370 | { 371 | right: { 372 | lineNumber: 1, 373 | type: 1, 374 | value: "My Updated Name", 375 | }, 376 | left: { 377 | lineNumber: 1, 378 | type: 2, 379 | value: "Hello World", 380 | }, 381 | }, 382 | { 383 | right: { 384 | lineNumber: 2, 385 | type: 1, 386 | value: "Also this info", 387 | }, 388 | left: {}, 389 | }, 390 | ], 391 | diffLines: [0, 1], 392 | }); 393 | }); 394 | 395 | it("Should start line counting from offset", (): void => { 396 | const oldCode = "Hello World"; 397 | const newCode = `My Updated Name 398 | Also this info`; 399 | 400 | expect( 401 | computeLineInformation(oldCode, newCode, true, DiffMethod.WORDS, 5), 402 | ).toMatchObject({ 403 | lineInformation: [ 404 | { 405 | right: { 406 | lineNumber: 6, 407 | type: 1, 408 | value: "My Updated Name", 409 | }, 410 | left: { 411 | lineNumber: 6, 412 | type: 2, 413 | value: "Hello World", 414 | }, 415 | }, 416 | { 417 | right: { 418 | lineNumber: 7, 419 | type: 1, 420 | value: "Also this info", 421 | }, 422 | left: {}, 423 | }, 424 | ], 425 | diffLines: [0, 1], 426 | }); 427 | }); 428 | }); 429 | -------------------------------------------------------------------------------- /test/react-diff-viewer.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment happy-dom 3 | */ 4 | 5 | import { render } from "@testing-library/react"; 6 | import * as React from "react"; 7 | import { describe, expect, it } from "vitest"; 8 | 9 | import DiffViewer from "../src/index"; 10 | 11 | const oldCode = ` 12 | const a = 123 13 | const b = 456 14 | const c = 4556 15 | const d = 4566 16 | const e = () => { 17 | console.log('c') 18 | } 19 | `; 20 | 21 | const newCode = ` 22 | const a = 123 23 | const b = 456 24 | const c = 4556 25 | const d = 4566 26 | const aa = 123 27 | const bb = 456 28 | `; 29 | 30 | describe("Testing react diff viewer", (): void => { 31 | it("It should render a table", (): void => { 32 | const node = render(); 33 | 34 | expect(node.getAllByRole("table").length).toEqual(1); 35 | }); 36 | 37 | it("It should render diff lines in diff view", (): void => { 38 | const node = render(); 39 | 40 | expect(node.getAllByRole("row").length).toEqual(16); 41 | }); 42 | 43 | it("It should render diff lines in inline view", (): void => { 44 | const node = render( 45 | , 46 | ); 47 | 48 | expect(node.getAllByRole("row").length).toEqual(26); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "outDir": "lib/esm", 7 | "declaration": false 8 | }, 9 | "include": ["src/", "examples/"], 10 | "exclude": ["node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "jsx": "react-jsx", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "esModuleInterop": true, 9 | "isolatedModules": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "target": "es2015", 13 | "declaration": true, 14 | "downlevelIteration": true, 15 | "lib": ["es2017", "dom"], 16 | "types": ["node"], 17 | "outDir": "lib/cjs" 18 | }, 19 | "include": ["src/", "examples/"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | environment: 'jsdom' 5 | }) 6 | --------------------------------------------------------------------------------