├── .eslintrc ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── enzyme.ts ├── examples └── src │ ├── diff │ ├── javascript │ │ ├── new.rjs │ │ └── old.rjs │ ├── json │ │ ├── new.json │ │ └── old.json │ └── xml │ │ ├── new.xml │ │ └── old.xml │ ├── index.ejs │ ├── index.tsx │ └── style.scss ├── logo-standalone.png ├── logo.png ├── package.json ├── publish-examples.sh ├── src ├── compute-lines.ts ├── index.tsx └── styles.ts ├── test ├── compute-lines-test.ts └── react-diff-viewer-test.tsx ├── tsconfig.examples.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "plugin:@typescript-eslint/recommended"], 3 | "rules": { 4 | "no-tabs": "off", 5 | "@typescript-eslint/indent": ["error", 2], 6 | "max-len": ["error", { 7 | "code": 100 8 | }], 9 | "arrow-body-style": "off", 10 | }, 11 | "settings": { 12 | "import/resolver": { 13 | "node": { 14 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 15 | } 16 | } 17 | }, 18 | "env": { 19 | "mocha": true, 20 | "node": true, 21 | "browser": true, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.buymeacoffee.com/cAHgxoB 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | test 3 | *.svg 4 | src 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 12 3 | before_script: yarn build 4 | after_success: './publish-examples.sh' 5 | deploy: 6 | skip_cleanup: true 7 | provider: npm 8 | email: praneshpranesh@gmail.com 9 | api_key: 10 | secure: ytHogDV6wWZKY3eCgdJ5TFCz57llxTe6W1EuLBbg3TvHfpAmcL5XzT77T0lNTYY95XmABwGpf4qvqui1fXVoFqqSXVPSVt9I1aQ4gxBzxPKHGPV++wwgVOBfZErIlYPW4kcYJQvfemBDBfAMQS2u/0si5mZbeyVkvlnhHLnylsc1sJUGGnq9PJxwCagBd/DAhYYCU5d4a9psOwo6GnAwzs37rgVIb5oNhbtex0r1u5D4irweVz4V1TkwhMczw3v8t0d+I2REqjGkqLdKJOt9Q/Uqc7v34wIAy4Xng8zla7PKlzVNDlsA19en4DezJ/YRU3o6JxsMICRDyIRcwaebX2S7S35cif2gJRSF+LRCq2XdHvDAXPt6trQoeo9mSdq6+dL4/uYbhliUOyhzw84CRdY3+VrST1h7h8LAGODQzYJbQs20PkVL2527Bk+eS+tNCNpr/jSWG/iDO477j9jTGNVloF2IRHikbnZDFcF4ZrKE5RHEWSbQzyHkOOTgo5k+p6Doyv4jSgdLlnck2+f5n4Ocs/xh1c0UAphdW/mw/8lAv0+gpoL8f3rfN4AD5LYjjQ7xK5yO6RfIzsmAOqs7WCyY0ewXaPaP6Nz+Ne5Q2CKwVZiqj3qvd4o3aJodnquJ1iKzxbNHd/LFScEQxYSslyRluHrM+7DASPfccA7ePnE= 11 | on: 12 | tags: true 13 | repo: praneshr/react-diff-viewer 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pranesh Ravi 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 | 2 |

3 | React Diff Viewer 4 |

5 |
6 | 7 | [![Build Status](https://travis-ci.com/praneshr/react-diff-viewer.svg?branch=master)](https://travis-ci.com/praneshr/react-diff-viewer) 8 | [![npm version](https://badge.fury.io/js/react-diff-viewer.svg)](https://badge.fury.io/js/react-diff-viewer) 9 | [![GitHub license](https://img.shields.io/github/license/praneshr/react-diff-viewer.svg)](https://github.com/praneshr/react-diff-viewer/blob/master/LICENSE) 10 | 11 | A simple and beautiful text diff viewer component made with [Diff](https://github.com/kpdecker/jsdiff) and [React](https://reactjs.org). 12 | 13 | Inspired from 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. 14 | 15 | Check [here](https://github.com/praneshr/react-diff-viewer/tree/v2.0) for v2.0 16 | 17 | ## Install 18 | 19 | ```bash 20 | yarn add react-diff-viewer 21 | 22 | # or 23 | 24 | npm i react-diff-viewer 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```javascript 30 | import React, { PureComponent } from 'react'; 31 | import ReactDiffViewer from 'react-diff-viewer'; 32 | 33 | const oldCode = ` 34 | const a = 10 35 | const b = 10 36 | const c = () => console.log('foo') 37 | 38 | if(a > 10) { 39 | console.log('bar') 40 | } 41 | 42 | console.log('done') 43 | `; 44 | const newCode = ` 45 | const a = 10 46 | const boo = 10 47 | 48 | if(a === 10) { 49 | console.log('bar') 50 | } 51 | `; 52 | 53 | class Diff extends PureComponent { 54 | render = () => { 55 | return ( 56 | 57 | ); 58 | }; 59 | } 60 | ``` 61 | 62 | ## Props 63 | 64 | | Prop | Type | Default | Description | 65 | | ------------------------- | --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 66 | | oldValue | `string` | `''` | Old value as string. | 67 | | newValue | `string` | `''` | New value as string. | 68 | | splitView | `boolean` | `true` | Switch between `unified` and `split` view. | 69 | | disableWordDiff | `boolean` | `false` | Show and hide word diff in a diff line. | 70 | | compareMethod | `DiffMethod` | `DiffMethod.CHARS` | 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. | 71 | | hideLineNumbers | `boolean` | `false` | Show and hide line numbers. | 72 | | renderContent | `function` | `undefined` | Render Prop API to render code in the diff viewer. Helpful for [syntax highlighting](#syntax-highlighting) | 73 | | onLineNumberClick | `function` | `undefined` | Event handler for line number click. `(lineId: string) => void` | 74 | | highlightLines | `array[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. | 75 | | showDiffOnly | `boolean` | `true` | Shows only the diffed lines and folds the unchanged lines | 76 | | extraLinesSurroundingDiff | `number` | `3` | Number of extra unchanged lines surrounding the diff. Works along with `showDiffOnly`. | 77 | | codeFoldMessageRenderer | `function` | `Expand {number} of lines ...` | Render Prop API to render code fold message. | 78 | | styles | `object` | `{}` | To override style variables and styles. Learn more about [overriding styles](#overriding-styles) | 79 | | useDarkTheme | `boolean` | `true` | To enable/disable dark theme. | 80 | | 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. | 81 | | rightTitle | `string` | `undefined` | Column title for right section of the diff in split view. This will be ignored in inline view. | 82 | | linesOffset | `number` | `0` | Number to start count code lines from. | 83 | 84 | ## Instance Methods 85 | 86 | `resetCodeBlocks()` - Resets the expanded code blocks to it's initial state. Return `true` on successful reset and `false` during unsuccessful reset. 87 | 88 | ## Syntax Highlighting 89 | 90 | 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. 91 | 92 | An example using [Prism JS](https://prismjs.com) 93 | 94 | ```html 95 | // Load Prism CSS 96 | 99 | 100 | // Load Prism JS 101 | 102 | ``` 103 | 104 | ```javascript 105 | import React, { PureComponent } from 'react'; 106 | import ReactDiffViewer from 'react-diff-viewer'; 107 | 108 | const oldCode = ` 109 | const a = 10 110 | const b = 10 111 | const c = () => console.log('foo') 112 | 113 | if(a > 10) { 114 | console.log('bar') 115 | } 116 | 117 | console.log('done') 118 | `; 119 | const newCode = ` 120 | const a = 10 121 | const boo = 10 122 | 123 | if(a === 10) { 124 | console.log('bar') 125 | } 126 | `; 127 | 128 | class Diff extends PureComponent { 129 | highlightSyntax = str => ( 130 |
136 |   );
137 | 
138 |   render = () => {
139 |     return (
140 |       
146 |     );
147 |   };
148 | }
149 | ```
150 | 
151 | ## Text block diff comparison
152 | 
153 | 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.
154 | 
155 | ```javascript
156 | enum DiffMethod {
157 |   CHARS = 'diffChars',
158 |   WORDS = 'diffWords',
159 |   WORDS_WITH_SPACE = 'diffWordsWithSpace',
160 |   LINES = 'diffLines',
161 |   TRIMMED_LINES = 'diffTrimmedLines',
162 |   SENTENCES = 'diffSentences',
163 |   CSS = 'diffCss',
164 | }
165 | ```
166 | 
167 | ```javascript
168 | import React, { PureComponent } from 'react';
169 | import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer';
170 | 
171 | const oldCode = `
172 | {
173 |   "name": "Original name",
174 |   "description": null
175 | }
176 | `;
177 | const newCode = `
178 | {
179 |   "name": "My updated name",
180 |   "description": "Brand new description",
181 |   "status": "running"
182 | }
183 | `;
184 | 
185 | class Diff extends PureComponent {
186 |   render = () => {
187 |     return (
188 |       
194 |     );
195 |   };
196 | }
197 | ```
198 | 
199 | ## Overriding Styles
200 | 
201 | 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.
202 | 
203 | Below are the default style variables and style object keys.
204 | 
205 | ```javascript
206 | 
207 | // Default variables and style keys
208 | 
209 | const defaultStyles = {
210 |   variables: {
211 |     light: {
212 |       diffViewerBackground: '#fff',
213 |       diffViewerColor: '#212529',
214 |       addedBackground: '#e6ffed',
215 |       addedColor: '#24292e',
216 |       removedBackground: '#ffeef0',
217 |       removedColor: '#24292e',
218 |       wordAddedBackground: '#acf2bd',
219 |       wordRemovedBackground: '#fdb8c0',
220 |       addedGutterBackground: '#cdffd8',
221 |       removedGutterBackground: '#ffdce0',
222 |       gutterBackground: '#f7f7f7',
223 |       gutterBackgroundDark: '#f3f1f1',
224 |       highlightBackground: '#fffbdd',
225 |       highlightGutterBackground: '#fff5b1',
226 |       codeFoldGutterBackground: '#dbedff',
227 |       codeFoldBackground: '#f1f8ff',
228 |       emptyLineBackground: '#fafbfc',
229 |       gutterColor: '#212529',
230 |       addedGutterColor: '#212529',
231 |       removedGutterColor: '#212529',
232 |       codeFoldContentColor: '#212529',
233 |       diffViewerTitleBackground: '#fafbfc',
234 |       diffViewerTitleColor: '#212529',
235 |       diffViewerTitleBorderColor: '#eee',
236 |     },
237 |     dark: {
238 |       diffViewerBackground: '#2e303c',
239 |       diffViewerColor: '#FFF',
240 |       addedBackground: '#044B53',
241 |       addedColor: 'white',
242 |       removedBackground: '#632F34',
243 |       removedColor: 'white',
244 |       wordAddedBackground: '#055d67',
245 |       wordRemovedBackground: '#7d383f',
246 |       addedGutterBackground: '#034148',
247 |       removedGutterBackground: '#632b30',
248 |       gutterBackground: '#2c2f3a',
249 |       gutterBackgroundDark: '#262933',
250 |       highlightBackground: '#2a3967',
251 |       highlightGutterBackground: '#2d4077',
252 |       codeFoldGutterBackground: '#21232b',
253 |       codeFoldBackground: '#262831',
254 |       emptyLineBackground: '#363946',
255 |       gutterColor: '#464c67',
256 |       addedGutterColor: '#8c8c8c',
257 |       removedGutterColor: '#8c8c8c',
258 |       codeFoldContentColor: '#555a7b',
259 |       diffViewerTitleBackground: '#2f323e',
260 |       diffViewerTitleColor: '#555a7b',
261 |       diffViewerTitleBorderColor: '#353846',
262 |     }
263 |   },
264 |   diffContainer?: {}, // style object
265 |   diffRemoved?: {}, // style object
266 |   diffAdded?: {}, // style object
267 |   marker?: {}, // style object
268 |   emptyGutter?: {}, // style object
269 |   highlightedLine?: {}, // style object
270 |   lineNumber?: {}, // style object
271 |   highlightedGutter?: {}, // style object
272 |   contentText?: {}, // style object
273 |   gutter?: {}, // style object
274 |   line?: {}, // style object
275 |   wordDiff?: {}, // style object
276 |   wordAdded?: {}, // style object
277 |   wordRemoved?: {}, // style object
278 |   codeFoldGutter?: {}, // style object
279 |   codeFold?: {}, // style object
280 |   emptyLine?: {}, // style object
281 |   content?: {}, // style object
282 |   titleBlock?: {}, // style object
283 |   splitView?: {}, // style object
284 | }
285 | ```
286 | 
287 | To override any style, just pass the new style object to the `styles` prop. New style will be computed using `Object.assign(default, override)`.
288 | 
289 | For keys other than `variables`, the value can either be an object or string interpolation.
290 | 
291 | ```javascript
292 | import React, { PureComponent } from 'react';
293 | import ReactDiffViewer from 'react-diff-viewer';
294 | 
295 | const oldCode = `
296 | const a = 10
297 | const b = 10
298 | const c = () => console.log('foo')
299 | 
300 | if(a > 10) {
301 |   console.log('bar')
302 | }
303 | 
304 | console.log('done')
305 | `;
306 | const newCode = `
307 | const a = 10
308 | const boo = 10
309 | 
310 | if(a === 10) {
311 |   console.log('bar')
312 | }
313 | `;
314 | 
315 | class Diff extends PureComponent {
316 |   highlightSyntax = str => (
317 |     
323 |   );
324 | 
325 |   render = () => {
326 |     const newStyles = {
327 |       variables: {
328 |         dark: {
329 |           highlightBackground: '#fefed5',
330 |           highlightGutterBackground: '#ffcd3c',
331 |         },
332 |       },
333 |       line: {
334 |         padding: '10px 2px',
335 |         '&:hover': {
336 |           background: '#a26ea1',
337 |         },
338 |       },
339 |     };
340 | 
341 |     return (
342 |       
349 |     );
350 |   };
351 | }
352 | ```
353 | 
354 | ## Local Development
355 | 
356 | ```bash
357 | yarn install
358 | yarn build # or use yarn build:watch
359 | yarn start:examples
360 | ```
361 | 
362 | Check package.json for more build scripts.
363 | 
364 | ## License
365 | 
366 | MIT
367 | 


--------------------------------------------------------------------------------
/enzyme.ts:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme'
2 | import * as Adapter from 'enzyme-adapter-react-16'
3 | configure({ adapter: new Adapter() })
4 | 


--------------------------------------------------------------------------------
/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 | module.exports = {
 7 |   entry: [
 8 |     'webpack/hot/dev-server',
 9 |     'webpack-hot-middleware/client?reload=true',
10 |     path.join(__dirname, 'app/main.js')
11 |   ],
12 |   output: {
13 |     path: path.join(__dirname, '/dist/'),
14 |     filename: '[name].js',
15 |     publicPath: '/'
16 |   },
17 |   module: {
18 |     loaders: [
19 |       {
20 |         test: /\.js?$/,
21 |         loader: 'babel',
22 |         exclude: /node_modules|lib/,
23 |       },
24 |       {
25 |         test: /\.json?$/,
26 |         loader: 'json'
27 |       },
28 |       {
29 |         test: /\.css$/,
30 |         loader: 'style!css?modules&localIdentName=[hash:base64:5]'
31 |       },
32 |       {
33 |         test: /\.scss$/,
34 |         loaders: [
35 |           'style?sourceMap',
36 |           'css?modules&importLoaders=1',
37 |           'sass?sourceMap'
38 |         ],
39 |         exclude: /node_modules|lib/
40 |       },
41 |     ],
42 |   },
43 |   plugins: [
44 |     new WriteFilePlugin(),
45 |     new ExtractTextPlugin('app.css', {
46 |       allChunks: true
47 |     }),
48 |     new HtmlWebpackPlugin({
49 |       template: 'app/index.tpl.html',
50 |       inject: 'body',
51 |       filename: 'index.html'
52 |     }),
53 |     new webpack.optimize.OccurenceOrderPlugin(),
54 |     new webpack.HotModuleReplacementPlugin(),
55 |     new webpack.NoErrorsPlugin(),
56 |     new webpack.DefinePlugin({
57 |       'process.env.NODE_ENV': JSON.stringify('dev')
58 |     })
59 |   ],
60 |   node: {
61 |     fs: 'empty'
62 |   }
63 | };
64 | 


--------------------------------------------------------------------------------
/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 |   plugins: [
64 |     new WriteFilePlugin(),
65 |     new ExtractTextPlugin('app.css', {
66 |       allChunks: true
67 |     }),
68 |     new HtmlWebpackPlugin({
69 |       template: 'app/index.tpl.html',
70 |       inject: 'body',
71 |       filename: 'index.html'
72 |     }),
73 |   ],
74 |   node: {
75 |     fs: 'empty'
76 |   }
77 | };
78 | 


--------------------------------------------------------------------------------
/examples/src/diff/json/new.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": "BSD",
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 |     "start": "webpack-dev-server --open --hot --inline"
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.ejs:
--------------------------------------------------------------------------------
 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 |   
27 |     
28 | 29 | 30 | 31 | 32 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/src/index.tsx: -------------------------------------------------------------------------------- 1 | require('./style.scss'); 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | 5 | import ReactDiff, { DiffMethod } from '../../lib/index'; 6 | 7 | const oldJs = require('./diff/javascript/old.rjs').default; 8 | const newJs = require('./diff/javascript/new.rjs').default; 9 | 10 | const logo = require('../../logo.png'); 11 | 12 | interface ExampleState { 13 | splitView?: boolean; 14 | highlightLine?: string[]; 15 | language?: string; 16 | enableSyntaxHighlighting?: boolean; 17 | compareMethod?: DiffMethod; 18 | } 19 | 20 | const P = (window as any).Prism; 21 | 22 | class Example extends React.Component<{}, ExampleState> { 23 | public constructor(props: any) { 24 | super(props); 25 | this.state = { 26 | highlightLine: [], 27 | enableSyntaxHighlighting: true, 28 | }; 29 | } 30 | 31 | private onLineNumberClick = ( 32 | id: string, 33 | e: React.MouseEvent, 34 | ): void => { 35 | let highlightLine = [id]; 36 | if (e.shiftKey && this.state.highlightLine.length === 1) { 37 | const [dir, oldId] = this.state.highlightLine[0].split('-'); 38 | const [newDir, newId] = id.split('-'); 39 | if (dir === newDir) { 40 | highlightLine = []; 41 | const lowEnd = Math.min(Number(oldId), Number(newId)); 42 | const highEnd = Math.max(Number(oldId), Number(newId)); 43 | for (let i = lowEnd; i <= highEnd; i++) { 44 | highlightLine.push(`${dir}-${i}`); 45 | } 46 | } 47 | } 48 | this.setState({ 49 | highlightLine, 50 | }); 51 | }; 52 | 53 | private syntaxHighlight = (str: string): any => { 54 | if (!str) return; 55 | const language = P.highlight(str, P.languages.javascript); 56 | return ; 57 | }; 58 | 59 | public render(): JSX.Element { 60 | 61 | return ( 62 |
63 |
64 |
65 |
66 | React Diff Viewer Logo 67 |
68 |

69 | A simple and beautiful text diff viewer made with{' '} 70 | 71 | Diff{' '} 72 | 73 | and{' '} 74 | 75 | React.{' '} 76 | 77 | Featuring split view, inline view, word diff, line highlight and more. 78 |

79 | 86 |
87 |
88 | 99 |
100 | 106 |
107 | ); 108 | } 109 | } 110 | 111 | ReactDOM.render(, document.getElementById('app')); 112 | -------------------------------------------------------------------------------- /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, monospace; 10 | direction: ltr; 11 | text-align: left; 12 | white-space: pre; 13 | word-spacing: normal; 14 | word-break: normal; 15 | font-size: 0.95em; 16 | line-height: 1.2em; 17 | -moz-tab-size: 4; 18 | -o-tab-size: 4; 19 | tab-size: 4; 20 | -webkit-hyphens: none; 21 | -moz-hyphens: none; 22 | -ms-hyphens: none; 23 | hyphens: none; 24 | } 25 | 26 | pre[class*="language-"]::-moz-selection, 27 | pre[class*="language-"] ::-moz-selection, 28 | code[class*="language-"]::-moz-selection, 29 | code[class*="language-"] ::-moz-selection { 30 | background: #b3d4fc; 31 | } 32 | 33 | pre[class*="language-"]::selection, 34 | pre[class*="language-"] ::selection, 35 | code[class*="language-"]::selection, 36 | code[class*="language-"] ::selection { 37 | background: #b3d4fc; 38 | } 39 | 40 | /* Code blocks */ 41 | 42 | pre[class*="language-"] { 43 | padding: 1em; 44 | margin: .5em 0; 45 | overflow: auto; 46 | border: 1px solid #dddddd; 47 | background-color: white; 48 | } 49 | 50 | :not(pre)>code[class*="language-"], 51 | pre[class*="language-"] {} 52 | 53 | /* Inline code */ 54 | 55 | :not(pre)>code[class*="language-"] { 56 | padding: .2em; 57 | padding-top: 1px; 58 | padding-bottom: 1px; 59 | background: #f8f8f8; 60 | border: 1px solid #dddddd; 61 | } 62 | 63 | .token.comment, 64 | .token.prolog, 65 | .token.doctype, 66 | .token.cdata { 67 | color: #999988; 68 | font-style: italic; 69 | } 70 | 71 | .token.namespace { 72 | opacity: .7; 73 | } 74 | 75 | .token.string, 76 | .token.attr-value { 77 | color: #e3116c; 78 | } 79 | 80 | .token.punctuation, 81 | .token.operator { 82 | color: #3bf5d4; 83 | /* no highlight */ 84 | } 85 | 86 | .token.entity, 87 | .token.url, 88 | .token.symbol, 89 | .token.number, 90 | .token.boolean, 91 | .token.variable, 92 | .token.constant, 93 | .token.property, 94 | .token.regex, 95 | .token.inserted { 96 | color: #36acaa; 97 | } 98 | 99 | .token.atrule, 100 | .token.keyword, 101 | .token.attr-name, 102 | .language-autohotkey .token.selector { 103 | color: #00a4db; 104 | } 105 | 106 | .token.function, 107 | .token.deleted, 108 | .language-autohotkey .token.tag { 109 | color: #069071; 110 | } 111 | 112 | .token.tag, 113 | .token.selector, 114 | .language-autohotkey .token.keyword { 115 | color: #ff9292; 116 | } 117 | 118 | .token.important, 119 | .token.function, 120 | .token.bold { 121 | font-weight: bold; 122 | } 123 | 124 | .token.italic { 125 | font-style: italic; 126 | } 127 | 128 | @import url('https://fonts.googleapis.com/css?family=Poppins:400,500,600,700,800'); 129 | 130 | body { 131 | font-family: 'Poppins', sans-serif; 132 | background-color: #262831; 133 | } 134 | 135 | .react-diff-viewer-example { 136 | a { 137 | color: #125dec; 138 | text-decoration: none; 139 | } 140 | 141 | .banner { 142 | padding: 70px 15px; 143 | text-align: center; 144 | 145 | .img-container { 146 | text-align: center; 147 | margin: 100px auto 60px; 148 | max-width: 700px; 149 | 150 | img { 151 | width: 100%; 152 | 153 | &.mobile { 154 | display: none; 155 | } 156 | } 157 | } 158 | 159 | .cta { 160 | margin-top: 60px; 161 | 162 | a { 163 | &:last-child { 164 | button { 165 | margin-right: 0; 166 | } 167 | } 168 | } 169 | 170 | button { 171 | font-size: 14px; 172 | background: #125dec; 173 | border: none; 174 | cursor: pointer; 175 | 176 | &:focus { 177 | background: #125dec; 178 | } 179 | } 180 | } 181 | 182 | p { 183 | max-width: 700px; 184 | font-size: 18px; 185 | margin: 0 auto; 186 | color: #FFF; 187 | } 188 | } 189 | 190 | .controls { 191 | margin: 50px 15px 15px; 192 | display: flex; 193 | align-items: center; 194 | justify-content: space-between; 195 | 196 | label { 197 | margin-left: 30px; 198 | } 199 | 200 | select { 201 | background-color: transparent; 202 | padding: 5px 15px; 203 | border-radius: 4px; 204 | border: 2px solid #ddd; 205 | } 206 | } 207 | 208 | .radial { 209 | background: linear-gradient(180deg, #363946 0%, #262931 100%); 210 | position: absolute; 211 | width: 100%; 212 | height: 600px; 213 | left: 0; 214 | z-index: -1; 215 | } 216 | 217 | .diff-viewer { 218 | max-width: 90%; 219 | margin: 0 auto; 220 | border-radius: 8px; 221 | overflow-x: auto; 222 | overflow-y: hidden; 223 | white-space: nowrap; 224 | box-shadow: 0 0 30px #1c1e25; 225 | 226 | a { 227 | color: inherit; 228 | } 229 | } 230 | 231 | footer { 232 | margin: 40px 0; 233 | color: #FFF; 234 | text-align: center; 235 | } 236 | } 237 | 238 | @media (max-width: 1023px) { 239 | .react-diff-viewer-example { 240 | .banner { 241 | .img-container { 242 | img { 243 | width: 80%; 244 | } 245 | } 246 | } 247 | 248 | p { 249 | font-size: 16px; 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /logo-standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tp1845/react-diff-viewer/d1f2619c434c4d3affb3b8560c0f9d276a23b127/logo-standalone.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tp1845/react-diff-viewer/d1f2619c434c4d3affb3b8560c0f9d276a23b127/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-diff-viewer", 3 | "version": "3.1.1", 4 | "private": false, 5 | "description": "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:praneshr/react-diff-viewer.git", 17 | "license": "MIT", 18 | "author": "Pranesh Ravi", 19 | "main": "lib/index", 20 | "typings": "lib/index", 21 | "scripts": { 22 | "build": "tsc --outDir lib/", 23 | "build:examples": "webpack --progress --colors", 24 | "build:watch": "tsc --outDir lib/ -w", 25 | "publish:examples": "NODE_ENV=production yarn build:examples && gh-pages -d examples/dist -r $GITHUB_REPO_URL", 26 | "publish:examples:local": "NODE_ENV=production yarn build:examples && gh-pages -d examples/dist", 27 | "start:examples": "webpack-dev-server --open --hot --inline", 28 | "test": "mocha --require ts-node/register --require enzyme.ts ./test/**", 29 | "test:watch": "mocha --require ts-node/register --require enzyme.ts --watch-extensions ts,tsx --watch ./test/**" 30 | }, 31 | "dependencies": { 32 | "classnames": "^2.2.6", 33 | "create-emotion": "^10.0.14", 34 | "diff": "^4.0.1", 35 | "emotion": "^10.0.14", 36 | "memoize-one": "^5.0.4", 37 | "prop-types": "^15.6.2" 38 | }, 39 | "devDependencies": { 40 | "@types/classnames": "^2.2.6", 41 | "@types/diff": "^4.0.2", 42 | "@types/enzyme": "^3.1.14", 43 | "@types/enzyme-adapter-react-16": "^1.0.3", 44 | "@types/expect": "^1.20.3", 45 | "@types/memoize-one": "^4.1.1", 46 | "@types/mocha": "^5.2.5", 47 | "@types/node": "^12.0.12", 48 | "@types/react": "^16.4.14", 49 | "@types/react-dom": "^16.0.8", 50 | "@types/webpack": "^4.4.13", 51 | "@typescript-eslint/eslint-plugin": "^1.11.0", 52 | "@typescript-eslint/parser": "^1.11.0", 53 | "css-loader": "^3.0.0", 54 | "enzyme": "^3.7.0", 55 | "enzyme-adapter-react-16": "^1.6.0", 56 | "eslint": "6.0.1", 57 | "eslint-config-airbnb": "17.1.1", 58 | "eslint-plugin-import": "^2.18.0", 59 | "eslint-plugin-jsx-a11y": "^6.2.3", 60 | "eslint-plugin-react": "^7.14.2", 61 | "expect": "^24.8.0", 62 | "favicons-webpack-plugin": "^0.0.9", 63 | "file-loader": "^4.0.0", 64 | "gh-pages": "^2.0.1", 65 | "html-webpack-plugin": "^3.2.0", 66 | "mini-css-extract-plugin": "^0.7.0", 67 | "mocha": "^6.1.4", 68 | "node-sass": "^7.0.0", 69 | "raw-loader": "^3.0.0", 70 | "react": "^16.5.2", 71 | "react-dom": "^16.5.2", 72 | "sass-loader": "^7.1.0", 73 | "spy": "^1.0.0", 74 | "ts-loader": "^6.0.4", 75 | "ts-node": "^8.3.0", 76 | "typescript": "^3.5.2", 77 | "webpack": "^4.20.2", 78 | "webpack-cli": "^3.1.1", 79 | "webpack-dev-server": "^3.1.9" 80 | }, 81 | "peerDependencies": { 82 | "react": "^15.3.0 || ^16.0.0", 83 | "react-dom": "^15.3.0 || ^16.0.0" 84 | }, 85 | "engines": { 86 | "node": ">= 8" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } 10 | 11 | // See https://github.com/kpdecker/jsdiff/tree/v4.0.1#api for more info on the below JsDiff methods 12 | export enum DiffMethod { 13 | CHARS = 'diffChars', 14 | WORDS = 'diffWords', 15 | WORDS_WITH_SPACE = 'diffWordsWithSpace', 16 | LINES = 'diffLines', 17 | TRIMMED_LINES = 'diffTrimmedLines', 18 | SENTENCES = 'diffSentences', 19 | CSS = 'diffCss', 20 | } 21 | 22 | export interface DiffInformation { 23 | value?: string | DiffInformation[]; 24 | lineNumber?: number; 25 | type?: DiffType; 26 | } 27 | 28 | export interface LineInformation { 29 | left?: DiffInformation; 30 | right?: DiffInformation; 31 | } 32 | 33 | export interface ComputedLineInformation { 34 | lineInformation: LineInformation[]; 35 | diffLines: number[]; 36 | } 37 | 38 | export interface ComputedDiffInformation { 39 | left?: DiffInformation[]; 40 | right?: DiffInformation[]; 41 | } 42 | 43 | // See https://github.com/kpdecker/jsdiff/tree/v4.0.1#change-objects for more info on JsDiff 44 | // Change Objects 45 | export interface JsDiffChangeObject { 46 | added?: boolean; 47 | removed?: boolean; 48 | value?: string; 49 | } 50 | 51 | /** 52 | * Splits diff text by new line and computes final list of diff lines based on 53 | * conditions. 54 | * 55 | * @param value Diff text from the js diff module. 56 | */ 57 | const constructLines = (value: string): string[] => { 58 | const lines = value.split('\n'); 59 | const isAllEmpty = lines.every((val): boolean => !val); 60 | if (isAllEmpty) { 61 | // This is to avoid added an extra new line in the UI. 62 | if (lines.length === 2) { 63 | return []; 64 | } 65 | lines.pop(); 66 | return lines; 67 | } 68 | 69 | const lastLine = lines[lines.length - 1]; 70 | const firstLine = lines[0]; 71 | // Remove the first and last element if they are new line character. This is 72 | // to avoid addition of extra new line in the UI. 73 | if (!lastLine) { 74 | lines.pop(); 75 | } 76 | if (!firstLine) { 77 | lines.shift(); 78 | } 79 | return lines; 80 | }; 81 | 82 | /** 83 | * Computes word diff information in the line. 84 | * [TODO]: Consider adding options argument for JsDiff text block comparison 85 | * 86 | * @param oldValue Old word in the line. 87 | * @param newValue New word in the line. 88 | * @param compareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 89 | */ 90 | const computeDiff = ( 91 | oldValue: string, 92 | newValue: string, 93 | compareMethod: string = DiffMethod.CHARS, 94 | ): ComputedDiffInformation => { 95 | const diffArray: JsDiffChangeObject[] = jsDiff[compareMethod]( 96 | oldValue, 97 | newValue, 98 | ); 99 | const computedDiff: ComputedDiffInformation = { 100 | left: [], 101 | right: [], 102 | }; 103 | diffArray.forEach( 104 | ({ added, removed, value }): DiffInformation => { 105 | const diffInformation: DiffInformation = {}; 106 | if (added) { 107 | diffInformation.type = DiffType.ADDED; 108 | diffInformation.value = value; 109 | computedDiff.right.push(diffInformation); 110 | } 111 | if (removed) { 112 | diffInformation.type = DiffType.REMOVED; 113 | diffInformation.value = value; 114 | computedDiff.left.push(diffInformation); 115 | } 116 | if (!removed && !added) { 117 | diffInformation.type = DiffType.DEFAULT; 118 | diffInformation.value = value; 119 | computedDiff.right.push(diffInformation); 120 | computedDiff.left.push(diffInformation); 121 | } 122 | return diffInformation; 123 | }, 124 | ); 125 | return computedDiff; 126 | }; 127 | 128 | /** 129 | * [TODO]: Think about moving common left and right value assignment to a 130 | * common place. Better readability? 131 | * 132 | * Computes line wise information based in the js diff information passed. Each 133 | * line contains information about left and right section. Left side denotes 134 | * deletion and right side denotes addition. 135 | * 136 | * @param oldString Old string to compare. 137 | * @param newString New string to compare with old string. 138 | * @param disableWordDiff Flag to enable/disable word diff. 139 | * @param compareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 140 | * @param linesOffset line number to start counting from 141 | */ 142 | const computeLineInformation = ( 143 | oldString: string, 144 | newString: string, 145 | disableWordDiff: boolean = false, 146 | compareMethod: string = DiffMethod.CHARS, 147 | linesOffset: number = 0, 148 | ): ComputedLineInformation => { 149 | const diffArray = diff.diffLines( 150 | oldString.trimRight(), 151 | newString.trimRight(), 152 | { 153 | newlineIsToken: true, 154 | ignoreWhitespace: false, 155 | ignoreCase: false, 156 | }, 157 | ); 158 | let rightLineNumber = linesOffset; 159 | let leftLineNumber = linesOffset; 160 | let lineInformation: LineInformation[] = []; 161 | let counter = 0; 162 | const diffLines: number[] = []; 163 | const ignoreDiffIndexes: string[] = []; 164 | const getLineInformation = ( 165 | value: string, 166 | diffIndex: number, 167 | added?: boolean, 168 | removed?: boolean, 169 | evaluateOnlyFirstLine?: boolean, 170 | ): LineInformation[] => { 171 | const lines = constructLines(value); 172 | 173 | return lines 174 | .map( 175 | (line: string, lineIndex): LineInformation => { 176 | const left: DiffInformation = {}; 177 | const right: DiffInformation = {}; 178 | if ( 179 | ignoreDiffIndexes.includes(`${diffIndex}-${lineIndex}`) || 180 | (evaluateOnlyFirstLine && lineIndex !== 0) 181 | ) { 182 | return undefined; 183 | } 184 | if (added || removed) { 185 | if (!diffLines.includes(counter)) { 186 | diffLines.push(counter); 187 | } 188 | if (removed) { 189 | leftLineNumber += 1; 190 | left.lineNumber = leftLineNumber; 191 | left.type = DiffType.REMOVED; 192 | left.value = line || ' '; 193 | // When the current line is of type REMOVED, check the next item in 194 | // the diff array whether it is of type ADDED. If true, the current 195 | // diff will be marked as both REMOVED and ADDED. Meaning, the 196 | // current line is a modification. 197 | const nextDiff = diffArray[diffIndex + 1]; 198 | if (nextDiff && nextDiff.added) { 199 | const nextDiffLines = constructLines(nextDiff.value)[lineIndex]; 200 | if (nextDiffLines) { 201 | const { 202 | value: rightValue, 203 | lineNumber, 204 | type, 205 | } = getLineInformation( 206 | nextDiff.value, 207 | diffIndex, 208 | true, 209 | false, 210 | true, 211 | )[0].right; 212 | // When identified as modification, push the next diff to ignore 213 | // list as the next value will be added in this line computation as 214 | // right and left values. 215 | ignoreDiffIndexes.push(`${diffIndex + 1}-${lineIndex}`); 216 | right.lineNumber = lineNumber; 217 | right.type = type; 218 | // Do word 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 | compareMethod, 227 | ); 228 | right.value = computedDiff.right; 229 | left.value = computedDiff.left; 230 | } 231 | } 232 | } 233 | } else { 234 | rightLineNumber += 1; 235 | right.lineNumber = rightLineNumber; 236 | right.type = DiffType.ADDED; 237 | right.value = line; 238 | } 239 | } else { 240 | leftLineNumber += 1; 241 | rightLineNumber += 1; 242 | 243 | left.lineNumber = leftLineNumber; 244 | left.type = DiffType.DEFAULT; 245 | left.value = line; 246 | right.lineNumber = rightLineNumber; 247 | right.type = DiffType.DEFAULT; 248 | right.value = line; 249 | } 250 | 251 | counter += 1; 252 | return { right, left }; 253 | }, 254 | ) 255 | .filter(Boolean); 256 | }; 257 | 258 | diffArray.forEach(({ added, removed, value }: diff.Change, index): void => { 259 | lineInformation = [ 260 | ...lineInformation, 261 | ...getLineInformation(value, index, added, removed), 262 | ]; 263 | }); 264 | 265 | return { 266 | lineInformation, 267 | diffLines, 268 | }; 269 | }; 270 | 271 | export { computeLineInformation }; 272 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import cn from 'classnames'; 4 | 5 | import { 6 | computeLineInformation, 7 | LineInformation, 8 | DiffInformation, 9 | DiffType, 10 | DiffMethod, 11 | } from './compute-lines'; 12 | import computeStyles, { 13 | ReactDiffViewerStylesOverride, 14 | ReactDiffViewerStyles, 15 | } from './styles'; 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const m = require('memoize-one'); 19 | 20 | const memoize = m.default || m; 21 | 22 | export enum LineNumberPrefix { 23 | LEFT = 'L', 24 | RIGHT = 'R', 25 | } 26 | 27 | export interface ReactDiffViewerProps { 28 | // Old value to compare. 29 | oldValue: string; 30 | // New value to compare. 31 | newValue: string; 32 | // Enable/Disable split view. 33 | splitView?: boolean; 34 | // Set line Offset 35 | linesOffset?: number; 36 | // Enable/Disable word diff. 37 | disableWordDiff?: boolean; 38 | // JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api 39 | compareMethod?: DiffMethod; 40 | // Number of unmodified lines surrounding each line diff. 41 | extraLinesSurroundingDiff?: number; 42 | // Show/hide line number. 43 | hideLineNumbers?: boolean; 44 | // Show only diff between the two values. 45 | showDiffOnly?: boolean; 46 | // Render prop to format final string before displaying them in the UI. 47 | renderContent?: (source: string) => JSX.Element; 48 | // Render prop to format code fold message. 49 | codeFoldMessageRenderer?: ( 50 | totalFoldedLines: number, 51 | leftStartLineNumber: number, 52 | rightStartLineNumber: number, 53 | ) => JSX.Element; 54 | // Event handler for line number click. 55 | onLineNumberClick?: ( 56 | lineId: string, 57 | event: React.MouseEvent, 58 | ) => void; 59 | // Array of line ids to highlight lines. 60 | highlightLines?: string[]; 61 | // Style overrides. 62 | styles?: ReactDiffViewerStylesOverride; 63 | // Use dark theme. 64 | useDarkTheme?: boolean; 65 | // Title for left column 66 | leftTitle?: string | JSX.Element; 67 | // Title for left column 68 | rightTitle?: string | JSX.Element; 69 | } 70 | 71 | export interface ReactDiffViewerState { 72 | // Array holding the expanded code folding. 73 | expandedBlocks?: number[]; 74 | } 75 | 76 | class DiffViewer extends React.Component< 77 | ReactDiffViewerProps, 78 | ReactDiffViewerState 79 | > { 80 | private styles: ReactDiffViewerStyles; 81 | 82 | public static defaultProps: ReactDiffViewerProps = { 83 | oldValue: '', 84 | newValue: '', 85 | splitView: true, 86 | highlightLines: [], 87 | disableWordDiff: false, 88 | compareMethod: DiffMethod.CHARS, 89 | styles: {}, 90 | hideLineNumbers: false, 91 | extraLinesSurroundingDiff: 3, 92 | showDiffOnly: true, 93 | useDarkTheme: false, 94 | linesOffset: 0, 95 | }; 96 | 97 | public static propTypes = { 98 | oldValue: PropTypes.string.isRequired, 99 | newValue: PropTypes.string.isRequired, 100 | splitView: PropTypes.bool, 101 | disableWordDiff: PropTypes.bool, 102 | compareMethod: PropTypes.oneOf(Object.values(DiffMethod)), 103 | renderContent: PropTypes.func, 104 | onLineNumberClick: PropTypes.func, 105 | extraLinesSurroundingDiff: PropTypes.number, 106 | styles: PropTypes.object, 107 | hideLineNumbers: PropTypes.bool, 108 | showDiffOnly: PropTypes.bool, 109 | highlightLines: PropTypes.arrayOf(PropTypes.string), 110 | leftTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 111 | rightTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 112 | linesOffset: PropTypes.number, 113 | }; 114 | 115 | public constructor(props: ReactDiffViewerProps) { 116 | super(props); 117 | 118 | this.state = { 119 | expandedBlocks: [], 120 | }; 121 | } 122 | 123 | /** 124 | * Resets code block expand to the initial stage. Will be exposed to the parent component via 125 | * refs. 126 | */ 127 | public resetCodeBlocks = (): boolean => { 128 | if (this.state.expandedBlocks.length > 0) { 129 | this.setState({ 130 | expandedBlocks: [], 131 | }); 132 | return true; 133 | } 134 | return false; 135 | }; 136 | 137 | /** 138 | * Pushes the target expanded code block to the state. During the re-render, 139 | * this value is used to expand/fold unmodified code. 140 | */ 141 | private onBlockExpand = (id: number): void => { 142 | const prevState = this.state.expandedBlocks.slice(); 143 | prevState.push(id); 144 | 145 | this.setState({ 146 | expandedBlocks: prevState, 147 | }); 148 | }; 149 | 150 | /** 151 | * Computes final styles for the diff viewer. It combines the default styles with the user 152 | * supplied overrides. The computed styles are cached with performance in mind. 153 | * 154 | * @param styles User supplied style overrides. 155 | */ 156 | private computeStyles: ( 157 | styles: ReactDiffViewerStylesOverride, 158 | useDarkTheme: boolean, 159 | ) => ReactDiffViewerStyles = memoize(computeStyles); 160 | 161 | /** 162 | * Returns a function with clicked line number in the closure. Returns an no-op function when no 163 | * onLineNumberClick handler is supplied. 164 | * 165 | * @param id Line id of a line. 166 | */ 167 | private onLineNumberClickProxy = (id: string): any => { 168 | if (this.props.onLineNumberClick) { 169 | return (e: any): void => this.props.onLineNumberClick(id, e); 170 | } 171 | return (): void => {}; 172 | }; 173 | 174 | /** 175 | * Maps over the word diff and constructs the required React elements to show word diff. 176 | * 177 | * @param diffArray Word diff information derived from line information. 178 | * @param renderer Optional renderer to format diff words. Useful for syntax highlighting. 179 | */ 180 | private renderWordDiff = ( 181 | diffArray: DiffInformation[], 182 | renderer?: (chunk: string) => JSX.Element, 183 | ): JSX.Element[] => { 184 | return diffArray.map( 185 | (wordDiff, i): JSX.Element => { 186 | return ( 187 | 193 | {renderer ? renderer(wordDiff.value as string) : wordDiff.value} 194 | 195 | ); 196 | }, 197 | ); 198 | }; 199 | 200 | /** 201 | * Maps over the line diff and constructs the required react elements to show line diff. It calls 202 | * renderWordDiff when encountering word diff. This takes care of both inline and split view line 203 | * renders. 204 | * 205 | * @param lineNumber Line number of the current line. 206 | * @param type Type of diff of the current line. 207 | * @param prefix Unique id to prefix with the line numbers. 208 | * @param value Content of the line. It can be a string or a word diff array. 209 | * @param additionalLineNumber Additional line number to be shown. Useful for rendering inline 210 | * diff view. Right line number will be passed as additionalLineNumber. 211 | * @param additionalPrefix Similar to prefix but for additional line number. 212 | */ 213 | private renderLine = ( 214 | lineNumber: number, 215 | type: DiffType, 216 | prefix: LineNumberPrefix, 217 | value: string | DiffInformation[], 218 | additionalLineNumber?: number, 219 | additionalPrefix?: LineNumberPrefix, 220 | ): JSX.Element => { 221 | const lineNumberTemplate = `${prefix}-${lineNumber}`; 222 | const additionalLineNumberTemplate = `${additionalPrefix}-${additionalLineNumber}`; 223 | const highlightLine = 224 | this.props.highlightLines.includes(lineNumberTemplate) || 225 | this.props.highlightLines.includes(additionalLineNumberTemplate); 226 | const added = type === DiffType.ADDED; 227 | const removed = type === DiffType.REMOVED; 228 | let content; 229 | if (Array.isArray(value)) { 230 | content = this.renderWordDiff(value, this.props.renderContent); 231 | } else if (this.props.renderContent) { 232 | content = this.props.renderContent(value); 233 | } else { 234 | content = value; 235 | } 236 | 237 | return ( 238 | 239 | {!this.props.hideLineNumbers && ( 240 | 250 |
{lineNumber}
251 | 252 | )} 253 | {!this.props.splitView && !this.props.hideLineNumbers && ( 254 | 265 |
{additionalLineNumber}
266 | 267 | )} 268 | 275 |
276 | 						{added && '+'}
277 | 						{removed && '-'}
278 | 					
279 | 280 | 287 |
{content}
288 | 289 |
290 | ); 291 | }; 292 | 293 | /** 294 | * Generates lines for split view. 295 | * 296 | * @param obj Line diff information. 297 | * @param obj.left Life diff information for the left pane of the split view. 298 | * @param obj.right Life diff information for the right pane of the split view. 299 | * @param index React key for the lines. 300 | */ 301 | private renderSplitView = ( 302 | { left, right }: LineInformation, 303 | index: number, 304 | ): JSX.Element => { 305 | return ( 306 | 307 | {this.renderLine( 308 | left.lineNumber, 309 | left.type, 310 | LineNumberPrefix.LEFT, 311 | left.value, 312 | )} 313 | {this.renderLine( 314 | right.lineNumber, 315 | right.type, 316 | LineNumberPrefix.RIGHT, 317 | right.value, 318 | )} 319 | 320 | ); 321 | }; 322 | 323 | /** 324 | * Generates lines for inline view. 325 | * 326 | * @param obj Line diff information. 327 | * @param obj.left Life diff information for the added section of the inline view. 328 | * @param obj.right Life diff information for the removed section of the inline view. 329 | * @param index React key for the lines. 330 | */ 331 | public renderInlineView = ( 332 | { left, right }: LineInformation, 333 | index: number, 334 | ): JSX.Element => { 335 | let content; 336 | if (left.type === DiffType.REMOVED && right.type === DiffType.ADDED) { 337 | return ( 338 | 339 | 340 | {this.renderLine( 341 | left.lineNumber, 342 | left.type, 343 | LineNumberPrefix.LEFT, 344 | left.value, 345 | null, 346 | )} 347 | 348 | 349 | {this.renderLine( 350 | null, 351 | right.type, 352 | LineNumberPrefix.RIGHT, 353 | right.value, 354 | right.lineNumber, 355 | )} 356 | 357 | 358 | ); 359 | } 360 | if (left.type === DiffType.REMOVED) { 361 | content = this.renderLine( 362 | left.lineNumber, 363 | left.type, 364 | LineNumberPrefix.LEFT, 365 | left.value, 366 | null, 367 | ); 368 | } 369 | if (left.type === DiffType.DEFAULT) { 370 | content = this.renderLine( 371 | left.lineNumber, 372 | left.type, 373 | LineNumberPrefix.LEFT, 374 | left.value, 375 | right.lineNumber, 376 | LineNumberPrefix.RIGHT, 377 | ); 378 | } 379 | if (right.type === DiffType.ADDED) { 380 | content = this.renderLine( 381 | null, 382 | right.type, 383 | LineNumberPrefix.RIGHT, 384 | right.value, 385 | right.lineNumber, 386 | ); 387 | } 388 | 389 | return ( 390 | 391 | {content} 392 | 393 | ); 394 | }; 395 | 396 | /** 397 | * Returns a function with clicked block number in the closure. 398 | * 399 | * @param id Cold fold block id. 400 | */ 401 | private onBlockClickProxy = (id: number): any => (): void => 402 | this.onBlockExpand(id); 403 | 404 | /** 405 | * Generates cold fold block. It also uses the custom message renderer when available to show 406 | * cold fold messages. 407 | * 408 | * @param num Number of skipped lines between two blocks. 409 | * @param blockNumber Code fold block id. 410 | * @param leftBlockLineNumber First left line number after the current code fold block. 411 | * @param rightBlockLineNumber First right line number after the current code fold block. 412 | */ 413 | private renderSkippedLineIndicator = ( 414 | num: number, 415 | blockNumber: number, 416 | leftBlockLineNumber: number, 417 | rightBlockLineNumber: number, 418 | ): JSX.Element => { 419 | const { hideLineNumbers, splitView } = this.props; 420 | const message = this.props.codeFoldMessageRenderer ? ( 421 | this.props.codeFoldMessageRenderer( 422 | num, 423 | leftBlockLineNumber, 424 | rightBlockLineNumber, 425 | ) 426 | ) : ( 427 |
Expand {num} lines ...
428 | ); 429 | const content = ( 430 | 431 | 432 | {message} 433 | 434 | 435 | ); 436 | const isUnifiedViewWithoutLineNumbers = !splitView && !hideLineNumbers; 437 | return ( 438 | 441 | {!hideLineNumbers && } 442 | 447 | 448 | {/* Swap columns only for unified view without line numbers */} 449 | {isUnifiedViewWithoutLineNumbers ? ( 450 | 451 | 452 | {content} 453 | 454 | ) : ( 455 | 456 | {content} 457 | 458 | 459 | )} 460 | 461 | 462 | 463 | 464 | ); 465 | }; 466 | 467 | /** 468 | * Generates the entire diff view. 469 | */ 470 | private renderDiff = (): JSX.Element[] => { 471 | const { 472 | oldValue, 473 | newValue, 474 | splitView, 475 | disableWordDiff, 476 | compareMethod, 477 | linesOffset, 478 | } = this.props; 479 | const { lineInformation, diffLines } = computeLineInformation( 480 | oldValue, 481 | newValue, 482 | disableWordDiff, 483 | compareMethod, 484 | linesOffset, 485 | ); 486 | const extraLines = 487 | this.props.extraLinesSurroundingDiff < 0 488 | ? 0 489 | : this.props.extraLinesSurroundingDiff; 490 | let skippedLines: number[] = []; 491 | return lineInformation.map( 492 | (line: LineInformation, i: number): JSX.Element => { 493 | const diffBlockStart = diffLines[0]; 494 | const currentPosition = diffBlockStart - i; 495 | if (this.props.showDiffOnly) { 496 | if (currentPosition === -extraLines) { 497 | skippedLines = []; 498 | diffLines.shift(); 499 | } 500 | if ( 501 | line.left.type === DiffType.DEFAULT && 502 | (currentPosition > extraLines || 503 | typeof diffBlockStart === 'undefined') && 504 | !this.state.expandedBlocks.includes(diffBlockStart) 505 | ) { 506 | skippedLines.push(i + 1); 507 | if (i === lineInformation.length - 1 && skippedLines.length > 1) { 508 | return this.renderSkippedLineIndicator( 509 | skippedLines.length, 510 | diffBlockStart, 511 | line.left.lineNumber, 512 | line.right.lineNumber, 513 | ); 514 | } 515 | return null; 516 | } 517 | } 518 | 519 | const diffNodes = splitView 520 | ? this.renderSplitView(line, i) 521 | : this.renderInlineView(line, i); 522 | 523 | if (currentPosition === extraLines && skippedLines.length > 0) { 524 | const { length } = skippedLines; 525 | skippedLines = []; 526 | return ( 527 | 528 | {this.renderSkippedLineIndicator( 529 | length, 530 | diffBlockStart, 531 | line.left.lineNumber, 532 | line.right.lineNumber, 533 | )} 534 | {diffNodes} 535 | 536 | ); 537 | } 538 | return diffNodes; 539 | }, 540 | ); 541 | }; 542 | 543 | public render = (): JSX.Element => { 544 | const { 545 | oldValue, 546 | newValue, 547 | useDarkTheme, 548 | leftTitle, 549 | rightTitle, 550 | splitView, 551 | hideLineNumbers, 552 | } = this.props; 553 | 554 | if (typeof oldValue !== 'string' || typeof newValue !== 'string') { 555 | throw Error('"oldValue" and "newValue" should be strings'); 556 | } 557 | 558 | this.styles = this.computeStyles(this.props.styles, useDarkTheme); 559 | const nodes = this.renderDiff(); 560 | const colSpanOnSplitView = hideLineNumbers ? 2 : 3; 561 | const colSpanOnInlineView = hideLineNumbers ? 2 : 4; 562 | 563 | const title = (leftTitle || rightTitle) && ( 564 | 565 | 568 |
{leftTitle}
569 | 570 | {splitView && ( 571 | 572 |
{rightTitle}
573 | 574 | )} 575 | 576 | ); 577 | 578 | return ( 579 | 583 | 584 | {title} 585 | {nodes} 586 | 587 |
588 | ); 589 | }; 590 | } 591 | 592 | export default DiffViewer; 593 | export { ReactDiffViewerStylesOverride, DiffMethod }; 594 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, cx } from 'emotion'; 2 | import { Interpolation } from 'create-emotion'; 3 | 4 | export interface ReactDiffViewerStyles { 5 | diffContainer?: string; 6 | diffRemoved?: string; 7 | diffAdded?: string; 8 | line?: string; 9 | highlightedGutter?: string; 10 | contentText?: string; 11 | gutter?: string; 12 | highlightedLine?: string; 13 | lineNumber?: string; 14 | marker?: string; 15 | wordDiff?: string; 16 | wordAdded?: string; 17 | wordRemoved?: string; 18 | codeFoldGutter?: string; 19 | emptyGutter?: string; 20 | emptyLine?: string; 21 | codeFold?: string; 22 | titleBlock?: string; 23 | content?: string; 24 | splitView?: string; 25 | [key: string]: string | undefined; 26 | } 27 | 28 | export interface ReactDiffViewerStylesVariables { 29 | diffViewerBackground?: string; 30 | diffViewerTitleBackground?: string; 31 | diffViewerColor?: string; 32 | diffViewerTitleColor?: string; 33 | diffViewerTitleBorderColor?: string; 34 | addedBackground?: string; 35 | addedColor?: string; 36 | removedBackground?: string; 37 | removedColor?: string; 38 | wordAddedBackground?: string; 39 | wordRemovedBackground?: string; 40 | addedGutterBackground?: string; 41 | removedGutterBackground?: string; 42 | gutterBackground?: string; 43 | gutterBackgroundDark?: string; 44 | highlightBackground?: string; 45 | highlightGutterBackground?: string; 46 | codeFoldGutterBackground?: string; 47 | codeFoldBackground?: string; 48 | emptyLineBackground?: string; 49 | gutterColor?: string; 50 | addedGutterColor?: string; 51 | removedGutterColor?: string; 52 | codeFoldContentColor?: string; 53 | } 54 | 55 | export interface ReactDiffViewerStylesOverride { 56 | variables?: { 57 | dark?: ReactDiffViewerStylesVariables; 58 | light?: ReactDiffViewerStylesVariables; 59 | }; 60 | diffContainer?: Interpolation; 61 | diffRemoved?: Interpolation; 62 | diffAdded?: Interpolation; 63 | marker?: Interpolation; 64 | emptyGutter?: Interpolation; 65 | highlightedLine?: Interpolation; 66 | lineNumber?: Interpolation; 67 | highlightedGutter?: Interpolation; 68 | contentText?: Interpolation; 69 | gutter?: Interpolation; 70 | line?: Interpolation; 71 | wordDiff?: Interpolation; 72 | wordAdded?: Interpolation; 73 | wordRemoved?: Interpolation; 74 | codeFoldGutter?: Interpolation; 75 | emptyLine?: Interpolation; 76 | content?: Interpolation; 77 | titleBlock?: Interpolation; 78 | splitView?: Interpolation; 79 | } 80 | 81 | export default ( 82 | styleOverride: ReactDiffViewerStylesOverride, 83 | useDarkTheme = false, 84 | ): ReactDiffViewerStyles => { 85 | const { variables: overrideVariables = {}, ...styles } = styleOverride; 86 | 87 | const themeVariables = { 88 | light: { 89 | ...{ 90 | diffViewerBackground: '#fff', 91 | diffViewerColor: '#212529', 92 | addedBackground: '#e6ffed', 93 | addedColor: '#24292e', 94 | removedBackground: '#ffeef0', 95 | removedColor: '#24292e', 96 | wordAddedBackground: '#acf2bd', 97 | wordRemovedBackground: '#fdb8c0', 98 | addedGutterBackground: '#cdffd8', 99 | removedGutterBackground: '#ffdce0', 100 | gutterBackground: '#f7f7f7', 101 | gutterBackgroundDark: '#f3f1f1', 102 | highlightBackground: '#fffbdd', 103 | highlightGutterBackground: '#fff5b1', 104 | codeFoldGutterBackground: '#dbedff', 105 | codeFoldBackground: '#f1f8ff', 106 | emptyLineBackground: '#fafbfc', 107 | gutterColor: '#212529', 108 | addedGutterColor: '#212529', 109 | removedGutterColor: '#212529', 110 | codeFoldContentColor: '#212529', 111 | diffViewerTitleBackground: '#fafbfc', 112 | diffViewerTitleColor: '#212529', 113 | diffViewerTitleBorderColor: '#eee', 114 | }, 115 | ...(overrideVariables.light || {}), 116 | }, 117 | dark: { 118 | ...{ 119 | diffViewerBackground: '#2e303c', 120 | diffViewerColor: '#FFF', 121 | addedBackground: '#044B53', 122 | addedColor: 'white', 123 | removedBackground: '#632F34', 124 | removedColor: 'white', 125 | wordAddedBackground: '#055d67', 126 | wordRemovedBackground: '#7d383f', 127 | addedGutterBackground: '#034148', 128 | removedGutterBackground: '#632b30', 129 | gutterBackground: '#2c2f3a', 130 | gutterBackgroundDark: '#262933', 131 | highlightBackground: '#2a3967', 132 | highlightGutterBackground: '#2d4077', 133 | codeFoldGutterBackground: '#21232b', 134 | codeFoldBackground: '#262831', 135 | emptyLineBackground: '#363946', 136 | gutterColor: '#464c67', 137 | addedGutterColor: '#8c8c8c', 138 | removedGutterColor: '#8c8c8c', 139 | codeFoldContentColor: '#555a7b', 140 | diffViewerTitleBackground: '#2f323e', 141 | diffViewerTitleColor: '#555a7b', 142 | diffViewerTitleBorderColor: '#353846', 143 | }, 144 | ...(overrideVariables.dark || {}), 145 | }, 146 | }; 147 | 148 | const variables = useDarkTheme ? themeVariables.dark : themeVariables.light; 149 | 150 | const content = css({ 151 | width: '100%', 152 | label: 'content', 153 | }); 154 | 155 | const splitView = css({ 156 | [`.${content}`]: { 157 | width: '50%', 158 | }, 159 | label: 'split-view', 160 | }); 161 | 162 | const diffContainer = css({ 163 | width: '100%', 164 | background: variables.diffViewerBackground, 165 | pre: { 166 | margin: 0, 167 | whiteSpace: 'pre-wrap', 168 | lineHeight: '25px', 169 | }, 170 | label: 'diff-container', 171 | borderCollapse: 'collapse', 172 | }); 173 | 174 | const codeFoldContent = css({ 175 | color: variables.codeFoldContentColor, 176 | label: 'code-fold-content', 177 | }); 178 | 179 | const contentText = css({ 180 | color: variables.diffViewerColor, 181 | label: 'content-text', 182 | }); 183 | 184 | const titleBlock = css({ 185 | background: variables.diffViewerTitleBackground, 186 | padding: 10, 187 | borderBottom: `1px solid ${variables.diffViewerTitleBorderColor}`, 188 | label: 'title-block', 189 | ':last-child': { 190 | borderLeft: `1px solid ${variables.diffViewerTitleBorderColor}`, 191 | }, 192 | [`.${contentText}`]: { 193 | color: variables.diffViewerTitleColor, 194 | }, 195 | }); 196 | 197 | const lineNumber = css({ 198 | color: variables.gutterColor, 199 | label: 'line-number', 200 | }); 201 | 202 | const diffRemoved = css({ 203 | background: variables.removedBackground, 204 | color: variables.removedColor, 205 | pre: { 206 | color: variables.removedColor, 207 | }, 208 | [`.${lineNumber}`]: { 209 | color: variables.removedGutterColor, 210 | }, 211 | label: 'diff-removed', 212 | }); 213 | 214 | const diffAdded = css({ 215 | background: variables.addedBackground, 216 | color: variables.addedColor, 217 | pre: { 218 | color: variables.addedColor, 219 | }, 220 | [`.${lineNumber}`]: { 221 | color: variables.addedGutterColor, 222 | }, 223 | label: 'diff-added', 224 | }); 225 | 226 | const wordDiff = css({ 227 | padding: 2, 228 | display: 'inline-flex', 229 | borderRadius: 1, 230 | label: 'word-diff', 231 | }); 232 | 233 | const wordAdded = css({ 234 | background: variables.wordAddedBackground, 235 | label: 'word-added', 236 | }); 237 | 238 | const wordRemoved = css({ 239 | background: variables.wordRemovedBackground, 240 | label: 'word-removed', 241 | }); 242 | 243 | const codeFoldGutter = css({ 244 | backgroundColor: variables.codeFoldGutterBackground, 245 | label: 'code-fold-gutter', 246 | }); 247 | 248 | const codeFold = css({ 249 | backgroundColor: variables.codeFoldBackground, 250 | height: 40, 251 | fontSize: 14, 252 | fontWeight: 700, 253 | label: 'code-fold', 254 | a: { 255 | textDecoration: 'underline !important', 256 | cursor: 'pointer', 257 | pre: { 258 | display: 'inline', 259 | }, 260 | }, 261 | }); 262 | 263 | const emptyLine = css({ 264 | backgroundColor: variables.emptyLineBackground, 265 | label: 'empty-line', 266 | }); 267 | 268 | const marker = css({ 269 | width: 25, 270 | paddingLeft: 10, 271 | paddingRight: 10, 272 | userSelect: 'none', 273 | label: 'marker', 274 | [`&.${diffAdded}`]: { 275 | pre: { 276 | color: variables.addedColor, 277 | }, 278 | }, 279 | [`&.${diffRemoved}`]: { 280 | pre: { 281 | color: variables.removedColor, 282 | }, 283 | }, 284 | }); 285 | 286 | const highlightedLine = css({ 287 | background: variables.highlightBackground, 288 | label: 'highlighted-line', 289 | [`.${wordAdded}, .${wordRemoved}`]: { 290 | backgroundColor: 'initial', 291 | }, 292 | }); 293 | 294 | const highlightedGutter = css({ 295 | label: 'highlighted-gutter', 296 | }); 297 | 298 | const gutter = css({ 299 | userSelect: 'none', 300 | minWidth: 50, 301 | padding: '0 10px', 302 | label: 'gutter', 303 | textAlign: 'right', 304 | background: variables.gutterBackground, 305 | '&:hover': { 306 | cursor: 'pointer', 307 | background: variables.gutterBackgroundDark, 308 | pre: { 309 | opacity: 1, 310 | }, 311 | }, 312 | pre: { 313 | opacity: 0.5, 314 | }, 315 | [`&.${diffAdded}`]: { 316 | background: variables.addedGutterBackground, 317 | }, 318 | [`&.${diffRemoved}`]: { 319 | background: variables.removedGutterBackground, 320 | }, 321 | [`&.${highlightedGutter}`]: { 322 | background: variables.highlightGutterBackground, 323 | '&:hover': { 324 | background: variables.highlightGutterBackground, 325 | }, 326 | }, 327 | }); 328 | 329 | const emptyGutter = css({ 330 | '&:hover': { 331 | background: variables.gutterBackground, 332 | cursor: 'initial', 333 | }, 334 | label: 'empty-gutter', 335 | }); 336 | 337 | const line = css({ 338 | verticalAlign: 'baseline', 339 | label: 'line', 340 | }); 341 | 342 | const defaultStyles: any = { 343 | diffContainer, 344 | diffRemoved, 345 | diffAdded, 346 | splitView, 347 | marker, 348 | highlightedGutter, 349 | highlightedLine, 350 | gutter, 351 | line, 352 | wordDiff, 353 | wordAdded, 354 | wordRemoved, 355 | codeFoldGutter, 356 | codeFold, 357 | emptyGutter, 358 | emptyLine, 359 | lineNumber, 360 | contentText, 361 | content, 362 | codeFoldContent, 363 | titleBlock, 364 | }; 365 | 366 | const computerOverrideStyles: ReactDiffViewerStyles = Object.keys( 367 | styles, 368 | ).reduce( 369 | (acc, key): ReactDiffViewerStyles => ({ 370 | ...acc, 371 | ...{ 372 | [key]: css((styles as any)[key]), 373 | }, 374 | }), 375 | {}, 376 | ); 377 | 378 | return Object.keys(defaultStyles).reduce( 379 | (acc, key): ReactDiffViewerStyles => ({ 380 | ...acc, 381 | ...{ 382 | [key]: computerOverrideStyles[key] 383 | ? cx(defaultStyles[key], computerOverrideStyles[key]) 384 | : defaultStyles[key], 385 | }, 386 | }), 387 | {}, 388 | ); 389 | }; 390 | -------------------------------------------------------------------------------- /test/compute-lines-test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect'; 2 | import { computeLineInformation, DiffMethod } from '../src/compute-lines'; 3 | 4 | describe('Testing compute lines utils', (): void => { 5 | it('Should it avoid trailing spaces', (): void => { 6 | const oldCode = `test 7 | 8 | 9 | `; 10 | const newCode = `test 11 | 12 | `; 13 | 14 | expect(computeLineInformation(oldCode, newCode)) 15 | .toMatchObject({ 16 | lineInformation: [ 17 | { 18 | left: { 19 | lineNumber: 1, 20 | type: 0, 21 | value: 'test', 22 | }, 23 | right: { 24 | lineNumber: 1, 25 | type: 0, 26 | value: 'test', 27 | }, 28 | }, 29 | ], 30 | diffLines: [], 31 | }); 32 | }); 33 | 34 | it('Should identify line addition', (): void => { 35 | const oldCode = 'test'; 36 | const newCode = `test 37 | newLine`; 38 | 39 | expect(computeLineInformation(oldCode, newCode)) 40 | .toMatchObject({ 41 | lineInformation: [ 42 | { 43 | right: { 44 | lineNumber: 1, 45 | type: 0, 46 | value: 'test', 47 | }, 48 | left: { 49 | lineNumber: 1, 50 | type: 0, 51 | value: 'test', 52 | }, 53 | }, 54 | { 55 | right: { 56 | lineNumber: 2, 57 | type: 1, 58 | value: ' newLine', 59 | }, 60 | left: {}, 61 | }, 62 | ], 63 | diffLines: [1], 64 | }); 65 | }); 66 | 67 | it('Should identify line deletion', (): void => { 68 | const oldCode = `test 69 | oldLine`; 70 | const newCode = 'test'; 71 | 72 | expect(computeLineInformation(oldCode, newCode)) 73 | .toMatchObject({ 74 | lineInformation: [ 75 | { 76 | right: { 77 | lineNumber: 1, 78 | type: 0, 79 | value: 'test', 80 | }, 81 | left: { 82 | lineNumber: 1, 83 | type: 0, 84 | value: 'test', 85 | }, 86 | }, 87 | { 88 | right: {}, 89 | left: { 90 | lineNumber: 2, 91 | type: 2, 92 | value: ' oldLine', 93 | }, 94 | }, 95 | ], 96 | diffLines: [1], 97 | }); 98 | }); 99 | 100 | it('Should identify line modification', (): void => { 101 | const oldCode = `test 102 | oldLine`; 103 | const newCode = `test 104 | newLine`; 105 | 106 | expect(computeLineInformation(oldCode, newCode, true)) 107 | .toMatchObject({ 108 | lineInformation: [ 109 | { 110 | right: { 111 | lineNumber: 1, 112 | type: 0, 113 | value: 'test', 114 | }, 115 | left: { 116 | lineNumber: 1, 117 | type: 0, 118 | value: 'test', 119 | }, 120 | }, 121 | { 122 | right: { 123 | lineNumber: 2, 124 | type: 1, 125 | value: ' newLine', 126 | }, 127 | left: { 128 | lineNumber: 2, 129 | type: 2, 130 | value: ' oldLine', 131 | }, 132 | }, 133 | ], 134 | diffLines: [1], 135 | }); 136 | }); 137 | 138 | it('Should identify word diff', (): void => { 139 | const oldCode = `test 140 | oldLine`; 141 | const newCode = `test 142 | newLine`; 143 | 144 | expect(computeLineInformation(oldCode, newCode)) 145 | .toMatchObject({ 146 | lineInformation: [ 147 | { 148 | right: { 149 | lineNumber: 1, 150 | type: 0, 151 | value: 'test', 152 | }, 153 | left: { 154 | lineNumber: 1, 155 | type: 0, 156 | value: 'test', 157 | }, 158 | }, 159 | { 160 | right: { 161 | lineNumber: 2, 162 | type: 1, 163 | value: [ 164 | { 165 | type: 0, 166 | value: ' ', 167 | }, 168 | { 169 | type: 1, 170 | value: 'new', 171 | }, 172 | { 173 | type: 0, 174 | value: 'Line', 175 | }, 176 | ], 177 | }, 178 | left: { 179 | lineNumber: 2, 180 | type: 2, 181 | value: [ 182 | { 183 | type: 0, 184 | value: ' ', 185 | }, 186 | { 187 | type: 2, 188 | value: 'old', 189 | }, 190 | { 191 | type: 0, 192 | value: 'Line', 193 | }, 194 | ], 195 | }, 196 | }, 197 | ], 198 | diffLines: [1], 199 | }); 200 | }); 201 | 202 | it('Should call "diffChars" jsDiff method when compareMethod is not provided', (): void => { 203 | const oldCode = 'Hello World'; 204 | const newCode = `My Updated Name 205 | Also this info`; 206 | 207 | expect(computeLineInformation(oldCode, newCode)) 208 | .toMatchObject({ 209 | lineInformation: [ 210 | { 211 | right: { 212 | lineNumber: 1, 213 | type: 1, 214 | value: [ 215 | { 216 | type: 1, 217 | value: 'My Updat', 218 | }, 219 | { 220 | type: 0, 221 | value: 'e', 222 | }, 223 | { 224 | type: 1, 225 | value: 'd', 226 | }, 227 | { 228 | type: 0, 229 | value: ' ', 230 | }, 231 | { 232 | type: 1, 233 | value: 'Name', 234 | }, 235 | ], 236 | }, 237 | left: { 238 | lineNumber: 1, 239 | type: 2, 240 | value: [ 241 | { 242 | type: 2, 243 | value: 'H', 244 | }, 245 | { 246 | type: 0, 247 | value: 'e', 248 | }, 249 | { 250 | type: 2, 251 | value: 'llo', 252 | }, 253 | { 254 | type: 0, 255 | value: ' ', 256 | }, 257 | { 258 | type: 2, 259 | value: 'World', 260 | }, 261 | ], 262 | }, 263 | }, 264 | { 265 | right: { 266 | lineNumber: 2, 267 | type: 1, 268 | value: 'Also this info', 269 | }, 270 | left: {}, 271 | }, 272 | ], 273 | diffLines: [ 274 | 0, 275 | 2, 276 | ], 277 | }); 278 | }); 279 | 280 | it('Should call "diffWords" jsDiff method when a compareMethod IS provided', (): void => { 281 | const oldCode = 'Hello World'; 282 | const newCode = `My Updated Name 283 | Also this info`; 284 | 285 | expect(computeLineInformation(oldCode, newCode, false, DiffMethod.WORDS)) 286 | .toMatchObject({ 287 | lineInformation: [ 288 | { 289 | right: { 290 | lineNumber: 1, 291 | type: 1, 292 | value: [ 293 | { 294 | type: 1, 295 | value: 'My', 296 | }, 297 | { 298 | type: 0, 299 | value: ' ', 300 | }, 301 | { 302 | type: 1, 303 | value: 'Updated Name', 304 | }, 305 | ], 306 | }, 307 | left: { 308 | lineNumber: 1, 309 | type: 2, 310 | value: [ 311 | { 312 | type: 2, 313 | value: 'Hello', 314 | }, 315 | { 316 | type: 0, 317 | value: ' ', 318 | }, 319 | { 320 | type: 2, 321 | value: 'World', 322 | }, 323 | ], 324 | }, 325 | }, 326 | { 327 | right: { 328 | lineNumber: 2, 329 | type: 1, 330 | value: 'Also this info', 331 | }, 332 | left: {}, 333 | }, 334 | ], 335 | diffLines: [ 336 | 0, 337 | 2, 338 | ], 339 | }); 340 | }); 341 | 342 | it('Should not call jsDiff method and not diff text when disableWordDiff is true', (): void => { 343 | const oldCode = 'Hello World'; 344 | const newCode = `My Updated Name 345 | Also this info`; 346 | 347 | expect(computeLineInformation(oldCode, newCode, true)) 348 | .toMatchObject({ 349 | lineInformation: [ 350 | { 351 | right: { 352 | lineNumber: 1, 353 | type: 1, 354 | value: 'My Updated Name', 355 | }, 356 | left: { 357 | lineNumber: 1, 358 | type: 2, 359 | value: 'Hello World', 360 | }, 361 | }, 362 | { 363 | right: { 364 | lineNumber: 2, 365 | type: 1, 366 | value: 'Also this info', 367 | }, 368 | left: {}, 369 | }, 370 | ], 371 | diffLines: [ 372 | 0, 373 | 2, 374 | ], 375 | }); 376 | }); 377 | 378 | it('Should start line counting from offset', (): void => { 379 | const oldCode = 'Hello World'; 380 | const newCode = `My Updated Name 381 | Also this info`; 382 | 383 | expect(computeLineInformation(oldCode, newCode, true, DiffMethod.WORDS, 5)) 384 | .toMatchObject({ 385 | lineInformation: [ 386 | { 387 | right: { 388 | lineNumber: 6, 389 | type: 1, 390 | value: 'My Updated Name', 391 | }, 392 | left: { 393 | lineNumber: 6, 394 | type: 2, 395 | value: 'Hello World', 396 | }, 397 | }, 398 | { 399 | right: { 400 | lineNumber: 7, 401 | type: 1, 402 | value: 'Also this info', 403 | }, 404 | left: {}, 405 | }, 406 | ], 407 | diffLines: [ 408 | 0, 409 | 2, 410 | ], 411 | }); 412 | }); 413 | }); 414 | -------------------------------------------------------------------------------- /test/react-diff-viewer-test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as expect from 'expect'; 4 | 5 | import DiffViewer from '../lib/index'; 6 | 7 | const oldCode = ` 8 | const a = 123 9 | const b = 456 10 | const c = 4556 11 | const d = 4566 12 | const e = () => { 13 | console.log('c') 14 | } 15 | `; 16 | 17 | const newCode = ` 18 | const a = 123 19 | const b = 456 20 | const c = 4556 21 | const d = 4566 22 | const aa = 123 23 | const bb = 456 24 | `; 25 | 26 | describe('Testing react diff viewer', (): void => { 27 | it('It should render a table', (): void => { 28 | const node = shallow(); 32 | 33 | expect(node.find('table').length).toEqual(1); 34 | }); 35 | 36 | it('It should render diff lines in diff view', (): void => { 37 | const node = shallow(); 41 | 42 | expect(node.find('table > tbody tr').length).toEqual(7); 43 | }); 44 | 45 | it('It should render diff lines in inline view', (): void => { 46 | const node = shallow(); 51 | 52 | expect(node.find('table > tbody tr').length).toEqual(9); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tsconfig.examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "jsx": "react", 5 | "module": "es6", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "target": "es5", 9 | "downlevelIteration": true, 10 | "lib": ["es2017", "dom"] 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "jsx": "react", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "target": "es5", 9 | "declaration": true, 10 | "downlevelIteration": true, 11 | "lib": ["es2017", "dom"], 12 | "types": [ 13 | "mocha", 14 | "node" 15 | ] 16 | }, 17 | "include": ["./src/*", "enzyme.js"], 18 | "exclude": ["node_modules"], 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const Css = require('mini-css-extract-plugin'); 4 | const FavIconsWebpackPlugin = require('favicons-webpack-plugin'); 5 | 6 | module.exports = { 7 | entry: { 8 | main: './examples/src/index.tsx', 9 | }, 10 | mode: process.env.NODE_ENV === 'production' ? 11 | 'production' : 'development', 12 | resolve: { 13 | extensions: ['.jsx', '.tsx', '.ts', '.scss', '.css', '.js'], 14 | }, 15 | output: { 16 | path: path.resolve(__dirname, 'examples/dist'), 17 | filename: '[name].js', 18 | }, 19 | devServer: { 20 | contentBase: path.resolve(__dirname, 'examples/dist'), 21 | port: 8000, 22 | hot: true, 23 | }, 24 | module: { 25 | rules: [{ 26 | test: /\.tsx?$/, 27 | use: [{ 28 | loader: 'ts-loader', 29 | options: { 30 | configFile: 'tsconfig.examples.json', 31 | }, 32 | }], 33 | exclude: /node_modules/, 34 | }, 35 | { 36 | test: /\.s?css$/, 37 | use: [ 38 | Css.loader, 39 | 'css-loader', 40 | 'sass-loader', 41 | ], 42 | }, 43 | { 44 | test: /\.xml|.rjs|.java/, 45 | use: 'raw-loader', 46 | }, 47 | { 48 | test: /\.svg|.png/, 49 | use: 'file-loader', 50 | }, 51 | ], 52 | }, 53 | plugins: [ 54 | new HtmlWebpackPlugin({ 55 | template: './examples/src/index.ejs', 56 | }), 57 | new FavIconsWebpackPlugin('./logo-standalone.png'), 58 | new Css({ 59 | filename: 'main.css', 60 | }), 61 | ], 62 | }; 63 | --------------------------------------------------------------------------------