├── src ├── __tests__ │ ├── setup.ts │ ├── components │ │ ├── Header.test.tsx │ │ ├── TextInput.test.tsx │ │ └── Toolbar.test.tsx │ ├── hooks │ │ ├── useFileUpload.test.ts │ │ └── useDiff.test.ts │ └── utils │ │ ├── textUtils.test.ts │ │ └── diffUtils.test.ts ├── main.tsx ├── types.ts ├── utils │ ├── textUtils.ts │ └── diffUtils.ts ├── index.css ├── hooks │ ├── useFileUpload.ts │ └── useDiff.ts ├── components │ ├── TextInput.tsx │ ├── Header.tsx │ ├── Toolbar.tsx │ └── DiffView.tsx ├── App.tsx ├── assets │ └── react.svg └── App.css ├── screenshot.png ├── tsconfig.json ├── vite.config.ts ├── .gitignore ├── index.html ├── eslint.config.js ├── tsconfig.node.json ├── vitest.config.ts ├── tsconfig.app.json ├── .github └── workflows │ ├── deploy.yml │ └── ci.yml ├── package.json ├── public └── vite.svg └── README.md /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohmrefresh/text-diff/main/screenshot.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | base: '/text-diff/', 7 | plugins: [react()], 8 | }) 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | coverage -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | text-diff 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Change } from 'diff' 2 | 3 | export type DiffMode = 'line' | 'word' 4 | export type FormatMode = 'highlight' | 'plain' 5 | 6 | export type DiffRowKind = 'unchanged' | 'removed' | 'added' | 'modified' 7 | 8 | export type DiffRow = { 9 | kind: DiffRowKind 10 | leftText?: string 11 | rightText?: string 12 | leftLineNumber?: number 13 | rightLineNumber?: number 14 | } 15 | 16 | export type { Change } 17 | -------------------------------------------------------------------------------- /src/utils/textUtils.ts: -------------------------------------------------------------------------------- 1 | export function normalizeNewlines(text: string): string { 2 | return text.replace(/\r\n/g, '\n') 3 | } 4 | 5 | export function splitLinesNoTrailingEmpty(text: string): string[] { 6 | const normalized = normalizeNewlines(text) 7 | const segments = normalized.split('\n') 8 | const lastIndex = segments.length - 1 9 | if (lastIndex >= 0 && segments[lastIndex] === '') segments.pop() 10 | return segments 11 | } 12 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 3 | sans-serif; 4 | line-height: 1.5; 5 | font-weight: 400; 6 | font-synthesis: none; 7 | text-rendering: optimizeLegibility; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | background-color: #f5f7fb; 11 | } 12 | 13 | *, 14 | *::before, 15 | *::after { 16 | box-sizing: border-box; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | min-height: 100vh; 22 | } 23 | 24 | a { 25 | color: inherit; 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useFileUpload.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from 'react' 2 | 3 | export function useFileUpload( 4 | onSuccess: (text: string, filename: string) => void, 5 | onError: (error: string) => void 6 | ) { 7 | const handleFileUpload = async (event: ChangeEvent) => { 8 | const file = event.target.files?.[0] 9 | if (!file) return 10 | 11 | try { 12 | const text = await file.text() 13 | onSuccess(text, file.name) 14 | } catch (error: unknown) { 15 | const message = error instanceof Error ? error.message : String(error); 16 | onError(`Failed to read file: ${message}`); 17 | } 18 | } 19 | 20 | return handleFileUpload 21 | } 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | environment: 'jsdom', 8 | globals: true, 9 | setupFiles: './src/__tests__/setup.ts', 10 | coverage: { 11 | provider: 'v8', 12 | reporter: ['text', 'json', 'html', 'lcov'], 13 | exclude: [ 14 | 'node_modules/', 15 | 'dist/', 16 | 'src/__tests__/', 17 | '**/*.test.ts', 18 | '**/*.test.tsx', 19 | 'src/main.tsx', 20 | 'vite.config.ts', 21 | 'vitest.config.ts', 22 | ], 23 | thresholds: { 24 | lines: 80, 25 | functions: 80, 26 | branches: 80, 27 | statements: 80, 28 | }, 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useDiff.ts: -------------------------------------------------------------------------------- 1 | import { useDeferredValue, useMemo } from 'react' 2 | import { diffLines } from 'diff' 3 | import type { Change } from '../types' 4 | import { normalizeNewlines } from '../utils/textUtils' 5 | import { buildAlignedRows, formatPlainDiff } from '../utils/diffUtils' 6 | 7 | export function useDiff(leftText: string, rightText: string) { 8 | const deferredLeft = useDeferredValue(leftText) 9 | const deferredRight = useDeferredValue(rightText) 10 | 11 | const lineDiff = useMemo(() => { 12 | if (!deferredLeft && !deferredRight) return [] 13 | return diffLines(normalizeNewlines(deferredLeft), normalizeNewlines(deferredRight)) 14 | }, [deferredLeft, deferredRight]) 15 | 16 | const alignedRows = useMemo(() => buildAlignedRows(lineDiff), [lineDiff]) 17 | 18 | const plainLines = useMemo(() => formatPlainDiff(lineDiff), [lineDiff]) 19 | const plainText = useMemo(() => plainLines.join('\n'), [plainLines]) 20 | const hasChanges = lineDiff.some((change) => change.added || change.removed) 21 | 22 | return { 23 | lineDiff, 24 | alignedRows, 25 | plainText, 26 | hasChanges, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: pages 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v6 23 | 24 | - name: Setup Node 25 | uses: actions/setup-node@v6 26 | with: 27 | node-version: 20 28 | cache: npm 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | - name: Test 37 | run: npm test -- --run 38 | 39 | - name: Build 40 | run: npm run build 41 | 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v4 44 | with: 45 | path: dist 46 | 47 | deploy: 48 | needs: build 49 | runs-on: ubuntu-latest 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | steps: 54 | - name: Deploy to GitHub Pages 55 | id: deployment 56 | uses: actions/deploy-pages@v4 57 | 58 | -------------------------------------------------------------------------------- /src/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from 'react' 2 | 3 | type TextInputProps = { 4 | label: string 5 | value: string 6 | onChange: (value: string) => void 7 | onFileUpload: (event: ChangeEvent) => void 8 | placeholder: string 9 | ariaLabel: string 10 | } 11 | 12 | export function TextInput({ 13 | label, 14 | value, 15 | onChange, 16 | onFileUpload, 17 | placeholder, 18 | ariaLabel, 19 | }: TextInputProps) { 20 | return ( 21 |
22 |
23 | {label} 24 | 33 |
34 |
35 |