├── 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 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "text-diff",
3 | "private": true,
4 | "version": "0.0.1",
5 | "homepage": "https://ohmrefresh.github.io/text-diff",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "deploy": "npm run build && gh-pages -d dist",
12 | "test": "vitest",
13 | "test:ui": "vitest --ui",
14 | "test:coverage": "vitest --coverage"
15 | },
16 | "dependencies": {
17 | "diff": "^8.0.2",
18 | "react": "^19.2.0",
19 | "react-dom": "^19.2.0"
20 | },
21 | "devDependencies": {
22 | "@eslint/js": "^9.39.1",
23 | "@testing-library/jest-dom": "^6.9.1",
24 | "@testing-library/react": "^16.3.0",
25 | "@testing-library/user-event": "^14.6.1",
26 | "@types/node": "^25.0.2",
27 | "@types/react": "^19.2.5",
28 | "@types/react-dom": "^19.2.3",
29 | "@vitejs/plugin-react": "^5.1.1",
30 | "@vitest/coverage-v8": "^4.0.15",
31 | "@vitest/ui": "^4.0.15",
32 | "eslint": "^9.39.1",
33 | "eslint-plugin-react-hooks": "^7.0.1",
34 | "eslint-plugin-react-refresh": "^0.4.24",
35 | "gh-pages": "^6.3.0",
36 | "globals": "^16.5.0",
37 | "jsdom": "^27.3.0",
38 | "typescript": "~5.9.3",
39 | "typescript-eslint": "^8.46.4",
40 | "vite": "^7.2.4",
41 | "vitest": "^4.0.15"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | type HeaderProps = {
2 | leftTextLength: number
3 | rightTextLength: number
4 | diffMode: string
5 | }
6 |
7 | export function Header({ leftTextLength, rightTextLength, diffMode }: HeaderProps) {
8 | return (
9 |
10 |
11 |
Client-side only
12 |
Compare text differences instantly
13 |
14 | Paste two blocks of text, choose a diff mode, and see additions,
15 | removals, and unchanged content highlighted in real time.
16 |
17 |
18 |
19 |
20 | Left
21 |
22 | {leftTextLength.toLocaleString()} chars
23 |
24 |
25 |
26 | Right
27 |
28 | {rightTextLength.toLocaleString()} chars
29 |
30 |
31 |
32 | Mode
33 |
34 | {diffMode === 'line' ? 'Line by line' : 'Word level'}
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches: [main, develop]
6 | push:
7 | branches: [develop]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [20]
15 |
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v6
19 |
20 | - name: Setup Node ${{ matrix.node-version }}
21 | uses: actions/setup-node@v6
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | cache: npm
25 |
26 | - name: Install dependencies
27 | run: npm ci
28 |
29 | - name: Lint
30 | run: npm run lint
31 |
32 | - name: Test
33 | run: npm test -- --run
34 |
35 | - name: Build
36 | run: npm run build
37 |
38 | test-coverage:
39 | runs-on: ubuntu-latest
40 | steps:
41 | - name: Checkout
42 | uses: actions/checkout@v6
43 |
44 | - name: Setup Node
45 | uses: actions/setup-node@v6
46 | with:
47 | node-version: 20
48 | cache: npm
49 |
50 | - name: Install dependencies
51 | run: npm ci
52 |
53 | - name: Test with coverage
54 | run: npm run test:coverage
55 |
56 | - name: Upload coverage reports
57 | uses: codecov/codecov-action@v5
58 | if: always()
59 | with:
60 | token: ${{ secrets.CODECOV_TOKEN }}
61 | fail_ci_if_error: false
62 |
--------------------------------------------------------------------------------
/src/__tests__/components/Header.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { render, screen } from '@testing-library/react'
3 | import { Header } from '../../components/Header'
4 |
5 | describe('Header', () => {
6 | it('should render the title', () => {
7 | render()
8 | expect(screen.getByText('Compare text differences instantly')).toBeInTheDocument()
9 | })
10 |
11 | it('should display left text length', () => {
12 | render()
13 | expect(screen.getByText('100 chars')).toBeInTheDocument()
14 | })
15 |
16 | it('should display right text length', () => {
17 | render()
18 | expect(screen.getByText('250 chars')).toBeInTheDocument()
19 | })
20 |
21 | it('should display line by line mode', () => {
22 | render()
23 | expect(screen.getByText('Line by line')).toBeInTheDocument()
24 | })
25 |
26 | it('should display word level mode', () => {
27 | render()
28 | expect(screen.getByText('Word level')).toBeInTheDocument()
29 | })
30 |
31 | it('should format large numbers with commas', () => {
32 | render()
33 | expect(screen.getByText('1,000,000 chars')).toBeInTheDocument()
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/src/__tests__/hooks/useFileUpload.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest'
2 | import { renderHook, waitFor } from '@testing-library/react'
3 | import { useFileUpload } from '../../hooks/useFileUpload'
4 |
5 | describe('useFileUpload', () => {
6 | it('should return a function', () => {
7 | const onSuccess = vi.fn()
8 | const onError = vi.fn()
9 | const { result } = renderHook(() => useFileUpload(onSuccess, onError))
10 |
11 | expect(typeof result.current).toBe('function')
12 | })
13 |
14 | it('should not call handlers if no file is selected', async () => {
15 | const onSuccess = vi.fn()
16 | const onError = vi.fn()
17 | const { result } = renderHook(() => useFileUpload(onSuccess, onError))
18 |
19 | const event = {
20 | target: { files: [] },
21 | } as unknown as React.ChangeEvent
22 |
23 | await result.current(event)
24 |
25 | expect(onSuccess).not.toHaveBeenCalled()
26 | expect(onError).not.toHaveBeenCalled()
27 | })
28 |
29 | it('should call onError if file reading fails', async () => {
30 | const onSuccess = vi.fn()
31 | const onError = vi.fn()
32 | const { result } = renderHook(() => useFileUpload(onSuccess, onError))
33 |
34 | const mockFile = {
35 | text: vi.fn().mockRejectedValue(new Error('Read error')),
36 | name: 'test.txt',
37 | }
38 | const event = {
39 | target: { files: [mockFile] },
40 | } as unknown as React.ChangeEvent
41 |
42 | await result.current(event)
43 |
44 | // Wait for onError to be called
45 | await waitFor(() => {
46 | expect(onError).toHaveBeenCalledWith('Failed to read file: Read error')
47 | })
48 | expect(onSuccess).not.toHaveBeenCalled()
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/src/__tests__/utils/textUtils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { normalizeNewlines, splitLinesNoTrailingEmpty } from '../../utils/textUtils'
3 |
4 | describe('textUtils', () => {
5 | describe('normalizeNewlines', () => {
6 | it('should convert CRLF to LF', () => {
7 | expect(normalizeNewlines('hello\r\nworld')).toBe('hello\nworld')
8 | })
9 |
10 | it('should leave LF unchanged', () => {
11 | expect(normalizeNewlines('hello\nworld')).toBe('hello\nworld')
12 | })
13 |
14 | it('should handle multiple CRLF', () => {
15 | expect(normalizeNewlines('line1\r\nline2\r\nline3')).toBe('line1\nline2\nline3')
16 | })
17 |
18 | it('should handle empty string', () => {
19 | expect(normalizeNewlines('')).toBe('')
20 | })
21 | })
22 |
23 | describe('splitLinesNoTrailingEmpty', () => {
24 | it('should split lines by newline', () => {
25 | expect(splitLinesNoTrailingEmpty('line1\nline2\nline3')).toEqual([
26 | 'line1',
27 | 'line2',
28 | 'line3',
29 | ])
30 | })
31 |
32 | it('should remove trailing empty line', () => {
33 | expect(splitLinesNoTrailingEmpty('line1\nline2\n')).toEqual(['line1', 'line2'])
34 | })
35 |
36 | it('should handle CRLF', () => {
37 | expect(splitLinesNoTrailingEmpty('line1\r\nline2\r\n')).toEqual(['line1', 'line2'])
38 | })
39 |
40 | it('should handle single line', () => {
41 | expect(splitLinesNoTrailingEmpty('single')).toEqual(['single'])
42 | })
43 |
44 | it('should handle empty string', () => {
45 | expect(splitLinesNoTrailingEmpty('')).toEqual([])
46 | })
47 |
48 | it('should keep empty lines in the middle', () => {
49 | expect(splitLinesNoTrailingEmpty('line1\n\nline3')).toEqual(['line1', '', 'line3'])
50 | })
51 | })
52 | })
53 |
--------------------------------------------------------------------------------
/src/components/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import type { DiffMode, FormatMode } from '../types'
2 |
3 | type ToolbarProps = {
4 | diffMode: DiffMode
5 | formatMode: FormatMode
6 | onDiffModeChange: (mode: DiffMode) => void
7 | onFormatModeChange: (mode: FormatMode) => void
8 | onCopy: () => void
9 | onDownload: () => void
10 | hasContent: boolean
11 | }
12 |
13 | export function Toolbar({
14 | diffMode,
15 | formatMode,
16 | onDiffModeChange,
17 | onFormatModeChange,
18 | onCopy,
19 | onDownload,
20 | hasContent,
21 | }: ToolbarProps) {
22 | return (
23 |
24 |
25 |
26 |
34 |
35 |
36 |
37 |
38 |
46 |
54 |
55 |
56 |
57 |
60 |
68 |
69 |
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/src/utils/diffUtils.ts:
--------------------------------------------------------------------------------
1 | import type { Change } from 'diff'
2 | import type { DiffRow } from '../types'
3 | import { splitLinesNoTrailingEmpty } from './textUtils'
4 |
5 | export function buildAlignedRows(lineDiff: Change[]): DiffRow[] {
6 | const rows: DiffRow[] = []
7 |
8 | let pendingRemoved: string[] = []
9 | let pendingAdded: string[] = []
10 | let leftLineNumber = 1
11 | let rightLineNumber = 1
12 |
13 | const flushPending = () => {
14 | if (pendingRemoved.length === 0 && pendingAdded.length === 0) return
15 | const max = Math.max(pendingRemoved.length, pendingAdded.length)
16 | for (let i = 0; i < max; i += 1) {
17 | const leftText = pendingRemoved[i]
18 | const rightText = pendingAdded[i]
19 | if (leftText != null && rightText != null) {
20 | rows.push({
21 | kind: 'modified',
22 | leftText,
23 | rightText,
24 | leftLineNumber: leftLineNumber++,
25 | rightLineNumber: rightLineNumber++,
26 | })
27 | } else if (leftText != null) {
28 | rows.push({
29 | kind: 'removed',
30 | leftText,
31 | leftLineNumber: leftLineNumber++,
32 | })
33 | } else if (rightText != null) {
34 | rows.push({
35 | kind: 'added',
36 | rightText,
37 | rightLineNumber: rightLineNumber++,
38 | })
39 | }
40 | }
41 |
42 | pendingRemoved = []
43 | pendingAdded = []
44 | }
45 |
46 | lineDiff.forEach((part) => {
47 | const lines = splitLinesNoTrailingEmpty(part.value)
48 | if (part.removed) {
49 | pendingRemoved.push(...lines)
50 | return
51 | }
52 | if (part.added) {
53 | pendingAdded.push(...lines)
54 | return
55 | }
56 |
57 | flushPending()
58 | lines.forEach((line) => {
59 | rows.push({
60 | kind: 'unchanged',
61 | leftText: line,
62 | rightText: line,
63 | leftLineNumber: leftLineNumber++,
64 | rightLineNumber: rightLineNumber++,
65 | })
66 | })
67 | })
68 |
69 | flushPending()
70 | return rows
71 | }
72 |
73 | export function formatPlainDiff(changes: Change[]): string[] {
74 | const lines: string[] = []
75 |
76 | changes.forEach((part) => {
77 | const prefix = part.added ? '+' : part.removed ? '-' : ' '
78 | const normalized = part.value.replace(/\r\n/g, '\n')
79 | const segments = normalized.split('\n')
80 |
81 | segments.forEach((line, index) => {
82 | const isTrailingEmpty = index === segments.length - 1 && line === ''
83 | if (isTrailingEmpty) return
84 | lines.push(`${prefix} ${line}`)
85 | })
86 | })
87 |
88 | return lines.length ? lines : ['No differences']
89 | }
90 |
--------------------------------------------------------------------------------
/src/__tests__/hooks/useDiff.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { renderHook } from '@testing-library/react'
3 | import { useDiff } from '../../hooks/useDiff'
4 |
5 | describe('useDiff', () => {
6 | it('should return empty arrays for empty input', () => {
7 | const { result } = renderHook(() => useDiff('', ''))
8 |
9 | expect(result.current.alignedRows).toEqual([])
10 | expect(result.current.plainText).toBe('No differences')
11 | expect(result.current.hasChanges).toBe(false)
12 | })
13 |
14 | it('should detect no changes for identical text', () => {
15 | const { result } = renderHook(() => useDiff('line1\nline2', 'line1\nline2'))
16 |
17 | expect(result.current.hasChanges).toBe(false)
18 | expect(result.current.alignedRows).toHaveLength(2)
19 | expect(result.current.alignedRows[0].kind).toBe('unchanged')
20 | })
21 |
22 | it('should detect added lines', () => {
23 | const { result } = renderHook(() => useDiff('line1', 'line1\nline2'))
24 |
25 | expect(result.current.hasChanges).toBe(true)
26 | expect(result.current.alignedRows).toHaveLength(2)
27 | // diffLines treats this differently - checks that there IS a difference detected
28 | expect(result.current.alignedRows.some(row => row.kind !== 'unchanged')).toBe(true)
29 | })
30 |
31 | it('should detect removed lines', () => {
32 | const { result } = renderHook(() => useDiff('line1\nline2', 'line1'))
33 |
34 | expect(result.current.hasChanges).toBe(true)
35 | expect(result.current.alignedRows).toHaveLength(2)
36 | // diffLines treats this differently - checks that there IS a difference detected
37 | expect(result.current.alignedRows.some(row => row.kind !== 'unchanged')).toBe(true)
38 | })
39 |
40 | it('should detect modified lines', () => {
41 | const { result } = renderHook(() => useDiff('line1\nline2', 'line1\nmodified'))
42 |
43 | expect(result.current.hasChanges).toBe(true)
44 | expect(result.current.alignedRows).toHaveLength(2)
45 | // diffLines treats this as removal + addition (modified)
46 | expect(result.current.alignedRows[1].kind).toBe('modified')
47 | })
48 |
49 | it('should generate plain text output', () => {
50 | const { result } = renderHook(() => useDiff('line1', 'line2'))
51 |
52 | expect(result.current.plainText).toContain('line1')
53 | expect(result.current.plainText).toContain('line2')
54 | })
55 |
56 | it('should update when input changes', () => {
57 | const { result, rerender } = renderHook(
58 | ({ left, right }) => useDiff(left, right),
59 | { initialProps: { left: 'old', right: 'old' } }
60 | )
61 |
62 | expect(result.current.hasChanges).toBe(false)
63 |
64 | rerender({ left: 'old', right: 'new' })
65 |
66 | expect(result.current.hasChanges).toBe(true)
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/src/__tests__/components/TextInput.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest'
2 | import { render, screen } from '@testing-library/react'
3 | import userEvent from '@testing-library/user-event'
4 | import { TextInput } from '../../components/TextInput'
5 |
6 | describe('TextInput', () => {
7 | it('should render with label', () => {
8 | render(
9 |
17 | )
18 | expect(screen.getByText('Test Label')).toBeInTheDocument()
19 | })
20 |
21 | it('should render textarea with value', () => {
22 | render(
23 |
31 | )
32 | const textarea = screen.getByRole('textbox', { name: 'Test input' })
33 | expect(textarea).toHaveValue('test content')
34 | })
35 |
36 | it('should call onChange when typing', async () => {
37 | const user = userEvent.setup()
38 | const handleChange = vi.fn()
39 | render(
40 |
48 | )
49 |
50 | const textarea = screen.getByRole('textbox', { name: 'Test input' })
51 | await user.type(textarea, 'hello')
52 |
53 | expect(handleChange).toHaveBeenCalled()
54 | })
55 |
56 | it('should render placeholder', () => {
57 | render(
58 |
66 | )
67 | expect(screen.getByPlaceholderText('Test placeholder')).toBeInTheDocument()
68 | })
69 |
70 | it('should render file upload button', () => {
71 | render(
72 |
80 | )
81 | expect(screen.getByTitle('Open file')).toBeInTheDocument()
82 | })
83 |
84 | it('should have correct file input accept attribute', () => {
85 | render(
86 |
94 | )
95 | const fileInput = screen.getByTitle('Open file').querySelector('input[type="file"]')
96 | expect(fileInput).toHaveAttribute(
97 | 'accept',
98 | '.txt,.md,.js,.ts,.jsx,.tsx,.html,.css,.json,.xml,.csv,.log,text/*'
99 | )
100 | })
101 | })
102 |
--------------------------------------------------------------------------------
/src/__tests__/components/Toolbar.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest'
2 | import { render, screen } from '@testing-library/react'
3 | import userEvent from '@testing-library/user-event'
4 | import { Toolbar } from '../../components/Toolbar'
5 |
6 | describe('Toolbar', () => {
7 | const defaultProps = {
8 | diffMode: 'line' as const,
9 | formatMode: 'highlight' as const,
10 | onDiffModeChange: vi.fn(),
11 | onFormatModeChange: vi.fn(),
12 | onCopy: vi.fn(),
13 | onDownload: vi.fn(),
14 | hasContent: true,
15 | }
16 |
17 | it('should render diff mode select', () => {
18 | render()
19 | expect(screen.getByLabelText('Diff mode')).toBeInTheDocument()
20 | })
21 |
22 | it('should call onDiffModeChange when mode is changed', async () => {
23 | const user = userEvent.setup()
24 | const handleModeChange = vi.fn()
25 | render()
26 |
27 | const select = screen.getByLabelText('Diff mode')
28 | await user.selectOptions(select, 'word')
29 |
30 | expect(handleModeChange).toHaveBeenCalledWith('word')
31 | })
32 |
33 | it('should render format toggle buttons', () => {
34 | render()
35 | expect(screen.getByText('Highlighted')).toBeInTheDocument()
36 | expect(screen.getByText('Plain text')).toBeInTheDocument()
37 | })
38 |
39 | it('should highlight active format button', () => {
40 | render()
41 | const highlightBtn = screen.getByText('Highlighted')
42 | expect(highlightBtn).toHaveClass('active')
43 | })
44 |
45 | it('should call onFormatModeChange when format button is clicked', async () => {
46 | const user = userEvent.setup()
47 | const handleFormatChange = vi.fn()
48 | render()
49 |
50 | const plainBtn = screen.getByText('Plain text')
51 | await user.click(plainBtn)
52 |
53 | expect(handleFormatChange).toHaveBeenCalledWith('plain')
54 | })
55 |
56 | it('should render copy button', () => {
57 | render()
58 | expect(screen.getByText('Copy diff')).toBeInTheDocument()
59 | })
60 |
61 | it('should call onCopy when copy button is clicked', async () => {
62 | const user = userEvent.setup()
63 | const handleCopy = vi.fn()
64 | render()
65 |
66 | await user.click(screen.getByText('Copy diff'))
67 |
68 | expect(handleCopy).toHaveBeenCalled()
69 | })
70 |
71 | it('should disable buttons when hasContent is false', () => {
72 | render()
73 | expect(screen.getByText('Copy diff')).toBeDisabled()
74 | expect(screen.getByText('Export as .txt')).toBeDisabled()
75 | })
76 |
77 | it('should render download button', () => {
78 | render()
79 | expect(screen.getByText('Export as .txt')).toBeInTheDocument()
80 | })
81 |
82 | it('should call onDownload when download button is clicked', async () => {
83 | const user = userEvent.setup()
84 | const handleDownload = vi.fn()
85 | render()
86 |
87 | await user.click(screen.getByText('Export as .txt'))
88 |
89 | expect(handleDownload).toHaveBeenCalled()
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import type { DiffMode, FormatMode } from './types'
3 | import { useDiff } from './hooks/useDiff'
4 | import { useFileUpload } from './hooks/useFileUpload'
5 | import { Header } from './components/Header'
6 | import { TextInput } from './components/TextInput'
7 | import { Toolbar } from './components/Toolbar'
8 | import { DiffView } from './components/DiffView'
9 | import './App.css'
10 |
11 | const INITIAL_LEFT = ``
12 | const INITIAL_RIGHT = ``
13 |
14 | function App() {
15 | const [leftText, setLeftText] = useState(INITIAL_LEFT)
16 | const [rightText, setRightText] = useState(INITIAL_RIGHT)
17 | const [diffMode, setDiffMode] = useState('line')
18 | const [formatMode, setFormatMode] = useState('highlight')
19 | const [status, setStatus] = useState('')
20 |
21 | const { alignedRows, plainText, hasChanges } = useDiff(leftText, rightText)
22 |
23 | const handleCopy = async () => {
24 | if (!plainText) return
25 | try {
26 | await navigator.clipboard.writeText(plainText)
27 | setStatus('Copied diff to clipboard')
28 | } catch {
29 | setStatus('Failed to copy to clipboard')
30 | }
31 | }
32 |
33 | const handleDownload = () => {
34 | if (!plainText) return
35 | const blob = new Blob([plainText], { type: 'text/plain' })
36 | const url = URL.createObjectURL(blob)
37 | const link = document.createElement('a')
38 | link.href = url
39 | link.download = 'text-diff.txt'
40 | link.click()
41 | setTimeout(() => URL.revokeObjectURL(url), 1000)
42 | setStatus('Downloaded diff as text')
43 | }
44 |
45 | const handleLeftFileUpload = useFileUpload(
46 | (text, filename) => {
47 | setLeftText(text)
48 | setStatus(`Loaded ${filename}`)
49 | },
50 | (error) => setStatus(error)
51 | )
52 |
53 | const handleRightFileUpload = useFileUpload(
54 | (text, filename) => {
55 | setRightText(text)
56 | setStatus(`Loaded ${filename}`)
57 | },
58 | (error) => setStatus(error)
59 | )
60 |
61 | return (
62 |
63 |
64 |
69 |
70 |
88 |
89 |
98 |
99 |
106 |
107 |
108 | )
109 | }
110 |
111 | export default App
112 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/__tests__/utils/diffUtils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { diffLines } from 'diff'
3 | import { buildAlignedRows, formatPlainDiff } from '../../utils/diffUtils'
4 |
5 | describe('diffUtils', () => {
6 | describe('buildAlignedRows', () => {
7 | it('should handle unchanged lines', () => {
8 | const changes = diffLines('line1\nline2', 'line1\nline2')
9 | const rows = buildAlignedRows(changes)
10 |
11 | expect(rows).toHaveLength(2)
12 | expect(rows[0]).toMatchObject({
13 | kind: 'unchanged',
14 | leftText: 'line1',
15 | rightText: 'line1',
16 | leftLineNumber: 1,
17 | rightLineNumber: 1,
18 | })
19 | })
20 |
21 | it('should handle added lines', () => {
22 | const changes = diffLines('line1', 'line1\nline2')
23 | const rows = buildAlignedRows(changes)
24 |
25 | expect(rows).toHaveLength(2)
26 | // Note: diffLines may treat this as a modified block
27 | expect(rows.some(row => row.rightText === 'line2')).toBe(true)
28 | })
29 |
30 | it('should handle removed lines', () => {
31 | const changes = diffLines('line1\nline2', 'line1')
32 | const rows = buildAlignedRows(changes)
33 |
34 | expect(rows).toHaveLength(2)
35 | // Note: diffLines may treat this as a modified block
36 | expect(rows.some(row => row.leftText === 'line2')).toBe(true)
37 | })
38 |
39 | it('should handle modified lines', () => {
40 | const changes = diffLines('line1\nline2', 'line1\nmodified')
41 | const rows = buildAlignedRows(changes)
42 |
43 | expect(rows).toHaveLength(2)
44 | expect(rows[0].kind).toBe('unchanged')
45 | expect(rows[1]).toMatchObject({
46 | kind: 'modified',
47 | leftText: 'line2',
48 | rightText: 'modified',
49 | leftLineNumber: 2,
50 | rightLineNumber: 2,
51 | })
52 | })
53 |
54 | it('should handle empty input', () => {
55 | const changes = diffLines('', '')
56 | const rows = buildAlignedRows(changes)
57 |
58 | expect(rows).toHaveLength(0)
59 | })
60 |
61 | it('should handle multiple changes', () => {
62 | const changes = diffLines('line1\nline2\nline3', 'line1\nmodified\nline3\nline4')
63 | const rows = buildAlignedRows(changes)
64 |
65 | expect(rows.length).toBeGreaterThanOrEqual(4)
66 | // Verify modifications exist
67 | expect(rows.some(row => row.kind === 'modified' || row.kind === 'added')).toBe(true)
68 | })
69 | })
70 |
71 | describe('formatPlainDiff', () => {
72 | it('should format unchanged lines with space prefix', () => {
73 | const changes = diffLines('line1', 'line1')
74 | const formatted = formatPlainDiff(changes)
75 |
76 | expect(formatted).toEqual([' line1'])
77 | })
78 |
79 | it('should format added lines with + prefix', () => {
80 | const changes = diffLines('', 'line1')
81 | const formatted = formatPlainDiff(changes)
82 |
83 | expect(formatted).toEqual(['+ line1'])
84 | })
85 |
86 | it('should format removed lines with - prefix', () => {
87 | const changes = diffLines('line1', '')
88 | const formatted = formatPlainDiff(changes)
89 |
90 | expect(formatted).toEqual(['- line1'])
91 | })
92 |
93 | it('should format mixed changes', () => {
94 | const changes = diffLines('line1\nline2', 'line1\nline3')
95 | const formatted = formatPlainDiff(changes)
96 |
97 | expect(formatted).toEqual([' line1', '- line2', '+ line3'])
98 | })
99 |
100 | it('should return "No differences" for empty input', () => {
101 | const changes = diffLines('', '')
102 | const formatted = formatPlainDiff(changes)
103 |
104 | expect(formatted).toEqual(['No differences'])
105 | })
106 |
107 | it('should handle CRLF line endings', () => {
108 | const changes = [{ value: 'line1\r\n', added: false, removed: false, count: 1 }]
109 | const formatted = formatPlainDiff(changes)
110 |
111 | expect(formatted).toEqual([' line1'])
112 | })
113 | })
114 | })
115 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # text-diff
2 |
3 | [](https://github.com/ohmrefresh/text-diff/actions/workflows/deploy.yml)
4 | [](https://github.com/ohmrefresh/text-diff/actions/workflows/ci.yml)
5 |
6 | Browser-only, client-side text diff viewer built with Vite + React + TypeScript.
7 |
8 | ## Setup
9 |
10 | ```bash
11 | npm install
12 | npm run dev # start Vite dev server
13 | npm run build # type-check and build for production
14 | ```
15 |
16 | ## Usage
17 |
18 | 1. Paste or type the **Original** text on the left and the **Updated** text on the right.
19 | 2. Choose a diff mode: **Line by line** or **Word level**.
20 | 3. Toggle formatting between **Highlighted** (color-coded) and **Plain text** output.
21 | 4. Copy the plain diff to your clipboard or export it as a `.txt` file.
22 |
23 | ## Features
24 |
25 | - Client-side rendering only—no backend or runtime server required.
26 | - Color-coded diff with added, removed, and unchanged segments.
27 | - Plain text view for exporting or sharing.
28 | - Line-by-line and word-level diff modes powered by a browser-compatible diff library.
29 | - Handles large inputs efficiently using deferred updates and memoized diff calculations.
30 | - Simple, responsive layout with side-by-side editors and diff output.
31 |
32 | ## Project Structure
33 |
34 | ```
35 | src/
36 | ├── components/ # React components
37 | │ ├── Header.tsx # Header with stats
38 | │ ├── TextInput.tsx # Text input component with file upload
39 | │ ├── Toolbar.tsx # Toolbar with controls
40 | │ └── DiffView.tsx # Diff visualization component
41 | ├── hooks/ # Custom React hooks
42 | │ ├── useDiff.ts # Hook for diff computation
43 | │ └── useFileUpload.ts # Hook for file upload handling
44 | ├── utils/ # Utility functions
45 | │ ├── textUtils.ts # Text processing utilities
46 | │ └── diffUtils.ts # Diff computation utilities
47 | ├── types.ts # TypeScript type definitions
48 | ├── App.tsx # Main application component
49 | ├── App.css # Application styles
50 | ├── main.tsx # Application entry point
51 | └── index.css # Global styles
52 | ```
53 |
54 | ## Code Organization
55 |
56 | The codebase follows a modular structure:
57 |
58 | - **Components**: Reusable UI components with clear responsibilities
59 | - **Hooks**: Custom hooks for managing complex state and side effects
60 | - **Utils**: Pure utility functions for text processing and diff computation
61 | - **Types**: Centralized TypeScript type definitions
62 |
63 | ## Testing
64 |
65 | The project uses Vitest for unit testing. Test files are organized in the `src/__tests__` directory:
66 |
67 | ```
68 | src/__tests__/
69 | ├── components/ # Component tests
70 | │ ├── Header.test.tsx
71 | │ ├── TextInput.test.tsx
72 | │ └── Toolbar.test.tsx
73 | ├── hooks/ # Hook tests
74 | │ ├── useDiff.test.ts
75 | │ └── useFileUpload.test.ts
76 | ├── utils/ # Utility function tests
77 | │ ├── textUtils.test.ts
78 | │ └── diffUtils.test.ts
79 | └── setup.ts # Test setup file
80 | ```
81 |
82 | ### Running Tests
83 |
84 | ```bash
85 | npm test # Run tests in watch mode
86 | npm run test:ui # Run tests with UI
87 | npm run test:coverage # Run tests with coverage report
88 | ```
89 |
90 | ### Test Coverage
91 |
92 | All tests passing: **7 test files, 54 tests**
93 |
94 | Tests cover:
95 | - ✅ **Text utility functions** (10 tests) - normalize newlines, split lines
96 | - ✅ **Diff computation logic** (12 tests) - building aligned rows, formatting output
97 | - ✅ **React components** (22 tests) - Header, TextInput, Toolbar
98 | - ✅ **Custom hooks** (10 tests) - useDiff, useFileUpload
99 |
100 | **Test Breakdown by Module:**
101 | - `textUtils.test.ts`: 10 tests covering text normalization and line splitting
102 | - `diffUtils.test.ts`: 12 tests covering diff row alignment and plain text formatting
103 | - `Header.test.tsx`: 6 tests covering header display and stats
104 | - `TextInput.test.tsx`: 6 tests covering textarea and file upload UI
105 | - `Toolbar.test.tsx`: 10 tests covering mode selection and action buttons
106 | - `useDiff.test.ts`: 7 tests covering diff computation hook
107 | - `useFileUpload.test.ts`: 3 tests covering file upload handler
108 |
--------------------------------------------------------------------------------
/src/components/DiffView.tsx:
--------------------------------------------------------------------------------
1 | import { diffWordsWithSpace } from 'diff'
2 | import type { DiffRow, DiffMode, FormatMode } from '../types'
3 |
4 | type DiffViewProps = {
5 | alignedRows: DiffRow[]
6 | diffMode: DiffMode
7 | formatMode: FormatMode
8 | hasChanges: boolean
9 | status: string
10 | }
11 |
12 | function getDiffRowClass(kind: DiffRow['kind'], side: 'left' | 'right'): string {
13 | if (kind === 'unchanged') {
14 | return 'unchanged'
15 | } else if (kind === 'removed') {
16 | return side === 'left' ? 'removed' : 'unchanged'
17 | } else if (kind === 'added') {
18 | return side === 'right' ? 'added' : 'unchanged'
19 | } else if (kind === 'modified') {
20 | return side === 'left' ? 'removed' : 'added'
21 | }
22 | return 'unchanged'
23 | }
24 |
25 | function renderHighlightedLine(row: DiffRow, side: 'left' | 'right', diffMode: DiffMode) {
26 | const text = side === 'left' ? row.leftText : row.rightText
27 | if (text == null) return
28 |
29 | if (
30 | diffMode === 'word' &&
31 | row.kind === 'modified' &&
32 | row.leftText != null &&
33 | row.rightText != null
34 | ) {
35 | const wordChanges = diffWordsWithSpace(row.leftText, row.rightText)
36 | return wordChanges
37 | .map((part, index) => {
38 | if (side === 'left' && part.added) return null
39 | if (side === 'right' && part.removed) return null
40 | const cls = part.added ? 'added' : part.removed ? 'removed' : 'unchanged'
41 | return (
42 |
43 | {part.value}
44 |
45 | )
46 | })
47 | .filter(Boolean)
48 | }
49 |
50 | const cls = getDiffRowClass(row.kind, side)
51 |
52 | return {text}
53 | }
54 |
55 | function renderPlainLine(row: DiffRow, side: 'left' | 'right') {
56 | const text = side === 'left' ? row.leftText : row.rightText
57 | if (text == null) return ''
58 |
59 | const prefix =
60 | side === 'left'
61 | ? row.kind === 'removed' || row.kind === 'modified'
62 | ? '-'
63 | : ' '
64 | : row.kind === 'added' || row.kind === 'modified'
65 | ? '+'
66 | : ' '
67 |
68 | return `${prefix} ${text}`
69 | }
70 |
71 | export function DiffView({ alignedRows, diffMode, formatMode, hasChanges, status }: DiffViewProps) {
72 | return (
73 |
74 |
75 |
76 |
Diff result
77 |
{hasChanges ? 'Changes detected' : 'No changes detected'}
78 |
79 | Switch between highlighted and plain views. Use the buttons to
80 | copy or export.
81 |
82 |
83 | {status ?
{status}
: null}
84 |
85 |
86 | {formatMode === 'highlight' ? (
87 |
88 | {alignedRows.length === 0 ? (
89 |
Start typing above to see the differences.
90 | ) : (
91 |
92 |
93 |
Original text
94 |
95 |
96 | {alignedRows.map((row, index) => (
97 |
98 | {renderHighlightedLine(row, 'left', diffMode)}
99 |
100 | ))}
101 |
102 |
103 |
104 |
105 |
106 |
Updated text
107 |
108 |
109 | {alignedRows.map((row, index) => (
110 |
111 | {renderHighlightedLine(row, 'right', diffMode)}
112 |
113 | ))}
114 |
115 |
116 |
117 |
118 | )}
119 |
120 | ) : (
121 |
122 | {alignedRows.length ? (
123 |
124 |
125 |
Original text
126 |
127 |
128 | {alignedRows
129 | .map((row) => renderPlainLine(row, 'left'))
130 | .join('\n')}
131 |
132 |
133 |
134 |
135 |
136 |
Updated text
137 |
138 |
139 | {alignedRows
140 | .map((row) => renderPlainLine(row, 'right'))
141 | .join('\n')}
142 |
143 |
144 |
145 |
146 | ) : (
147 | 'Start typing above to see the differences.'
148 | )}
149 |
150 | )}
151 |
152 | )
153 | }
154 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | width: 100%;
3 | }
4 |
5 | :root {
6 | --diff-font-family: 'SFMono-Regular', ui-monospace, Menlo, Consolas, 'Liberation Mono',
7 | monospace;
8 | --diff-font-size: 15px;
9 | --diff-line-height: 1.65;
10 | --diff-font-style: normal;
11 | }
12 |
13 | .input-card__header span,
14 | .diff-card__header .eyebrow,
15 | .diff-pane__label {
16 | font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
17 | font-weight: 800;
18 | }
19 |
20 | .page {
21 | min-height: 100vh;
22 | background: radial-gradient(circle at 10% 20%, #ecf4ff, #f5f7fb 45%),
23 | linear-gradient(180deg, #f8fafc 0%, #edf2f7 100%);
24 | padding: 24px 0 48px;
25 | color: #0f172a;
26 | }
27 |
28 | .container {
29 | width: min(1400px, 98%);
30 | margin: 0 auto;
31 | display: flex;
32 | flex-direction: column;
33 | gap: 18px;
34 | }
35 |
36 | .hero {
37 | background: #ffffff;
38 | border: 1px solid #e2e8f0;
39 | border-radius: 16px;
40 | padding: 20px 24px;
41 | display: flex;
42 | align-items: flex-start;
43 | justify-content: space-between;
44 | gap: 12px;
45 | box-shadow: 0 18px 38px -28px rgba(15, 23, 42, 0.32);
46 | }
47 |
48 | .eyebrow {
49 | text-transform: uppercase;
50 | letter-spacing: 0.08em;
51 | font-weight: 700;
52 | color: #3b82f6;
53 | margin: 0 0 4px;
54 | font-size: 12px;
55 | }
56 |
57 | h1 {
58 | margin: 0 0 8px;
59 | font-size: clamp(22px, 3vw, 32px);
60 | color: #0f172a;
61 | }
62 |
63 | .lede {
64 | margin: 0;
65 | color: #475569;
66 | }
67 |
68 | .stats {
69 | display: grid;
70 | grid-template-columns: repeat(3, minmax(120px, 1fr));
71 | gap: 10px;
72 | }
73 |
74 | .stats div {
75 | background: #f8fafc;
76 | border: 1px solid #e2e8f0;
77 | border-radius: 12px;
78 | padding: 12px 14px;
79 | }
80 |
81 | .stat-label {
82 | display: block;
83 | color: #64748b;
84 | font-size: 12px;
85 | letter-spacing: 0.02em;
86 | }
87 |
88 | .stat-value {
89 | font-weight: 700;
90 | font-size: 16px;
91 | color: #0f172a;
92 | }
93 |
94 | .input-grid {
95 | display: grid;
96 | grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
97 | gap: 14px;
98 | }
99 |
100 | .input-card {
101 | background: #ffffff;
102 | border: 1px solid #e2e8f0;
103 | border-radius: 14px;
104 | padding: 12px 12px 4px;
105 | box-shadow: 0 8px 24px -18px rgba(15, 23, 42, 0.28);
106 | display: flex;
107 | flex-direction: column;
108 | }
109 |
110 | .input-card__header {
111 | display: flex;
112 | align-items: center;
113 | justify-content: space-between;
114 | color: #0f172a;
115 | font-weight: 700;
116 | margin-bottom: 6px;
117 | }
118 |
119 | .file-upload-btn {
120 | cursor: pointer;
121 | font-size: 20px;
122 | padding: 4px 8px;
123 | border-radius: 8px;
124 | transition: background-color 0.18s ease;
125 | display: inline-flex;
126 | align-items: center;
127 | justify-content: center;
128 | }
129 |
130 | .file-upload-btn:hover {
131 | background-color: #f1f5f9;
132 | }
133 |
134 | .textarea-wrapper {
135 | display: flex;
136 | position: relative;
137 | }
138 |
139 | textarea {
140 | width: 100%;
141 | min-height: 220px;
142 | border-radius: 10px;
143 | border: 1px solid #cbd5e1;
144 | padding: 12px;
145 | resize: vertical;
146 | font-family: 'SFMono-Regular', ui-monospace, Menlo, Consolas, 'Liberation Mono',
147 | monospace;
148 | font-size: 14px;
149 | color: #0f172a;
150 | background: #f8fafc;
151 | transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
152 | }
153 |
154 | textarea:focus {
155 | outline: none;
156 | border-color: #3b82f6;
157 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
158 | background: #ffffff;
159 | }
160 |
161 | .toolbar {
162 | display: flex;
163 | gap: 14px;
164 | flex-wrap: wrap;
165 | align-items: center;
166 | justify-content: space-between;
167 | background: #ffffff;
168 | border: 1px solid #e2e8f0;
169 | border-radius: 14px;
170 | padding: 12px 14px;
171 | box-shadow: 0 8px 24px -18px rgba(15, 23, 42, 0.28);
172 | }
173 |
174 | .control-group {
175 | display: flex;
176 | gap: 8px;
177 | align-items: center;
178 | flex-wrap: wrap;
179 | }
180 |
181 | .control-group label {
182 | font-weight: 700;
183 | color: #0f172a;
184 | display: flex;
185 | align-items: center;
186 | cursor: pointer;
187 | }
188 |
189 | .control-group input[type="checkbox"] {
190 | cursor: pointer;
191 | width: 16px;
192 | height: 16px;
193 | margin: 0;
194 | }
195 |
196 | select {
197 | border-radius: 10px;
198 | border: 1px solid #cbd5e1;
199 | padding: 8px 10px;
200 | font-weight: 600;
201 | background: #f8fafc;
202 | color: #0f172a;
203 | cursor: pointer;
204 | transition: border-color 0.18s ease, box-shadow 0.18s ease;
205 | }
206 |
207 | select:focus {
208 | outline: none;
209 | border-color: #3b82f6;
210 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
211 | }
212 |
213 | .button-group {
214 | display: inline-flex;
215 | gap: 8px;
216 | }
217 |
218 | button {
219 | border-radius: 10px;
220 | border: 1px solid #3b82f6;
221 | padding: 9px 14px;
222 | font-size: 14px;
223 | font-weight: 700;
224 | cursor: pointer;
225 | background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
226 | color: #ffffff;
227 | box-shadow: 0 6px 16px -8px rgba(37, 99, 235, 0.6);
228 | transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease,
229 | border-color 0.16s ease;
230 | }
231 |
232 | button:hover:not(:disabled) {
233 | transform: translateY(-1px);
234 | box-shadow: 0 12px 22px -14px rgba(37, 99, 235, 0.8);
235 | }
236 |
237 | button:disabled {
238 | cursor: not-allowed;
239 | opacity: 0.6;
240 | box-shadow: none;
241 | }
242 |
243 | button.ghost {
244 | background: transparent;
245 | color: #0f172a;
246 | border-color: #cbd5e1;
247 | box-shadow: none;
248 | }
249 |
250 | .button-group button {
251 | background: #f8fafc;
252 | color: #0f172a;
253 | border-color: #cbd5e1;
254 | box-shadow: none;
255 | }
256 |
257 | .button-group button.active {
258 | background: #2563eb;
259 | color: #ffffff;
260 | border-color: #2563eb;
261 | }
262 |
263 | .actions {
264 | display: flex;
265 | gap: 10px;
266 | flex-wrap: wrap;
267 | }
268 |
269 | .diff-card {
270 | background: #ffffff;
271 | border: 1px solid #e2e8f0;
272 | border-radius: 16px;
273 | padding: 16px;
274 | display: flex;
275 | flex-direction: column;
276 | gap: 12px;
277 | box-shadow: 0 18px 38px -28px rgba(15, 23, 42, 0.32);
278 | }
279 |
280 | .diff-card__header {
281 | display: flex;
282 | align-items: center;
283 | justify-content: space-between;
284 | gap: 10px;
285 | flex-wrap: wrap;
286 | }
287 |
288 | .diff-card h2 {
289 | margin: 2px 0 4px;
290 | color: #0f172a;
291 | }
292 |
293 | .muted {
294 | color: #475569;
295 | margin: 0;
296 | }
297 |
298 | .status {
299 | background: #ecfeff;
300 | color: #0e7490;
301 | border: 1px solid #67e8f9;
302 | border-radius: 12px;
303 | padding: 8px 12px;
304 | font-weight: 700;
305 | }
306 |
307 | .diff-output {
308 | border-radius: 14px;
309 | border: 1px dashed #cbd5e1;
310 | min-height: 180px;
311 | padding: 12px;
312 | font-family: 'SFMono-Regular', ui-monospace, Menlo, Consolas, 'Liberation Mono',
313 | monospace;
314 | background: #f8fafc;
315 | white-space: pre-wrap;
316 | word-break: break-word;
317 | line-height: 1.6;
318 | }
319 |
320 | .diff-output.highlighted {
321 | display: block;
322 | }
323 |
324 | .diff-output.plain {
325 | margin: 0;
326 | }
327 |
328 | .diff-split {
329 | display: grid;
330 | grid-template-columns: repeat(2, minmax(0, 1fr));
331 | gap: 12px;
332 | }
333 |
334 | .diff-pane {
335 | border: 1px solid #cbd5e1;
336 | border-radius: 12px;
337 | overflow: hidden;
338 | background: #ffffff;
339 | }
340 |
341 | .diff-pane__label {
342 | padding: 10px 12px;
343 | font-weight: 800;
344 | color: #0f172a;
345 | border-bottom: 1px solid #e2e8f0;
346 | background: #ffffff;
347 | }
348 |
349 | .diff-pane__body {
350 | background: #f8fafc;
351 | }
352 |
353 | .diff-pane__body .diff-content {
354 | padding: 12px;
355 | }
356 |
357 | .diff-lines {
358 | white-space: pre-wrap;
359 | word-break: break-word;
360 | }
361 |
362 | .diff-line {
363 | min-height: 1.6em;
364 | }
365 |
366 | .diff-content {
367 | flex: 1;
368 | padding: 12px;
369 | overflow-x: auto;
370 | margin: 0;
371 | }
372 |
373 | .chunk {
374 | padding: 2px 4px;
375 | border-radius: 6px;
376 | display: inline;
377 | white-space: pre-wrap;
378 | }
379 |
380 | .chunk.added {
381 | background: #e6f4ea;
382 | color: #166534;
383 | }
384 |
385 | .chunk.removed {
386 | background: #fee4e2;
387 | color: #b42318;
388 | }
389 |
390 | .chunk.unchanged {
391 | color: #0f172a;
392 | }
393 |
394 | .placeholder {
395 | margin: 0;
396 | color: #94a3b8;
397 | }
398 |
399 | @media (max-width: 768px) {
400 | .hero {
401 | flex-direction: column;
402 | }
403 |
404 | .stats {
405 | width: 100%;
406 | grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
407 | }
408 |
409 | .toolbar {
410 | flex-direction: column;
411 | align-items: flex-start;
412 | }
413 |
414 | .actions {
415 | width: 100%;
416 | }
417 |
418 | .actions button {
419 | flex: 1 1 auto;
420 | }
421 |
422 | .diff-split {
423 | grid-template-columns: 1fr;
424 | }
425 | }
426 |
--------------------------------------------------------------------------------