diff --git a/README.md b/README.md index 79559a4..601202b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,26 @@ export function App() { | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `original` | `string` | - | Original text content | -| `modified` | `string` | - | Modified text content | +| `original` | `string` | - | Original text content (new API) | +| `modified` | `string` | - | Modified text content (new API) | +| `oldValue` | `string` | - | Compatibility API: same as `original` | +| `newValue` | `string` | - | Compatibility API: same as `modified` | +| `splitView` | `boolean` | `true` | `true` for side-by-side, `false` for unified/inline | +| `showDiffOnly` | `boolean` | `true` | Show only changed lines with collapsible unchanged blocks | | `contextLines` | `number` | `2` | Number of unchanged lines kept around diff hunks | +| `extraLinesSurroundingDiff` | `number` | - | Compatibility API alias for context lines | +| `hideLineNumbers` | `boolean` | `false` | Hide line number columns | +| `highlightLines` | `Array<'L-n' \| 'R-n' \| range>` | - | Highlight specific lines (`L-3`, `R-5`, `L-10-15`) | +| `onLineNumberClick` | `(lineId) => void` | - | Called when a line number is clicked | +| `renderContent` | `(line: string) => ReactNode` | - | Custom line content renderer (syntax highlighting etc.) | +| `compareMethod` | `"CHARS" \| "WORDS" \| "WORDS_WITH_SPACE" \| "LINES" \| "TRIMMED_LINES" \| "SENTENCES" \| "CSS"` | `"LINES"` | Diff compare strategy | +| `disableWordDiff` | `boolean` | `false` | Disable inline word-level diff highlighting | +| `leftTitle` | `ReactNode` | - | Left pane title (split view) | +| `rightTitle` | `ReactNode` | - | Right pane title (split view) | +| `linesOffset` | `number` | `0` | Add offset to displayed line numbers | +| `useDarkTheme` | `boolean` | `false` | Built-in dark theme | +| `styles` | `Partial` | - | Override style slots | +| `codeFoldMessageRenderer` | `({ hiddenCount, expanded }) => ReactNode` | - | Custom fold button content renderer | | `height` | `number \| string` | `500` | Viewport height of the virtual list | | `locale` | `DiffViewerLocale` | - | UI text localization | | `language` | `string` | - | Reserved field for future language-related extensions | @@ -83,11 +100,11 @@ Metrics: - initial render time - memory usage (`usedJSHeapSize`) -Quick highlights from latest report: +Quick highlights from the latest report (`2026-04-08T06:45:43.686Z`): -- At `10k` lines: ~`60 FPS`, `187.2 ms` initial render, `9.5 MB` memory. -- At `50k/100k` lines: `react-diff-viewer` and `react-diff-viewer-continued` timeout. -- At `100k` lines: `141.1 MB` (`react-virtualized-diff`) vs `1297.0 MB` (`react-diff-view`). +- At `10k` lines (`react-virtualized-diff`): `60.8 FPS`, `127.0 ms` initial render, `9.5 MB` memory. +- At `50k/100k` lines: `react-diff-viewer` and `react-diff-viewer-continued` both timeout (`60000 ms` per case). +- At `100k` lines: `104.0 MB` (`react-virtualized-diff`) vs `1297.0 MB` (`react-diff-view`). Run benchmark: diff --git a/README.zh-CN.md b/README.zh-CN.md index a68157d..abb506a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -83,11 +83,11 @@ export function App() { - 首次渲染时间 - 内存占用(`usedJSHeapSize`) -最新结果亮点: +最新结果亮点(`2026-04-08T06:45:43.686Z`): -- `10k` 行:约 `60 FPS`,首渲染 `187.2 ms`,内存 `9.5 MB`。 -- `50k/100k` 行:`react-diff-viewer` 与 `react-diff-viewer-continued` 超时。 -- `100k` 行:本库 `141.1 MB`,`react-diff-view` 为 `1297.0 MB`。 +- `10k` 行(`react-virtualized-diff`):`60.8 FPS`,首渲染 `127.0 ms`,内存 `9.5 MB`。 +- `50k/100k` 行:`react-diff-viewer` 与 `react-diff-viewer-continued` 均超时(单 case 超时 `60000 ms`)。 +- `100k` 行:本库 `104.0 MB`,`react-diff-view` 为 `1297.0 MB`。 运行方式: diff --git a/apps/demo/src/components/PlaygroundControls.tsx b/apps/demo/src/components/PlaygroundControls.tsx index 5650067..b7acbe5 100644 --- a/apps/demo/src/components/PlaygroundControls.tsx +++ b/apps/demo/src/components/PlaygroundControls.tsx @@ -4,6 +4,17 @@ export type DemoState = { dataset: DatasetKey; contextLines: number; height: number; + splitView: boolean; + showDiffOnly: boolean; + hideLineNumbers: boolean; + useCompatApi: boolean; + enableRenderContent: boolean; + enableHighlight: boolean; + compareMethod: 'LINES' | 'WORDS' | 'CHARS'; + disableWordDiff: boolean; + useDarkTheme: boolean; + linesOffset: number; + useCustomFoldRenderer: boolean; }; type PlaygroundControlsProps = { @@ -82,6 +93,184 @@ export default function PlaygroundControls(props: PlaygroundControlsProps) { /> +
+ + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + onChange({ + ...value, + linesOffset: Number(event.target.value), + }) + } + /> +
+ diff --git a/apps/demo/src/pages/DemoPage.tsx b/apps/demo/src/pages/DemoPage.tsx index d9d23ff..12c53ed 100644 --- a/apps/demo/src/pages/DemoPage.tsx +++ b/apps/demo/src/pages/DemoPage.tsx @@ -3,14 +3,25 @@ import SiteHeader from '../components/SiteHeader'; import PlaygroundControls, { type DemoState } from '../components/PlaygroundControls'; import MetricsBar from '../components/MetricsBar'; import { generateDiffText, getDatasetByKey } from '../data/presets'; +import benchmarkPayload from '../../../../benchmark-results/results.json'; -// 按你的库实际导出改这里 -import { DiffViewer } from 'react-virtualized-diff'; +import { DiffViewer, type DiffViewerHandle, type DiffViewerProps, type LineId } from '../../../../packages/react/src'; const INITIAL_STATE: DemoState = { dataset: 'medium', contextLines: 3, height: 560, + splitView: true, + showDiffOnly: true, + hideLineNumbers: false, + useCompatApi: false, + enableRenderContent: false, + enableHighlight: false, + compareMethod: 'LINES', + disableWordDiff: false, + useDarkTheme: false, + linesOffset: 0, + useCustomFoldRenderer: false, }; type PerfMetrics = { @@ -19,6 +30,43 @@ type PerfMetrics = { totalTime: number | null; }; +type BenchmarkRow = { + lib: string; + lines: number; + initialRenderTimeMs: number | null; + averageFps: number | null; + memoryBytes: number | null; + status: 'ok' | 'timeout'; +}; + +type BenchmarkPayload = { + generatedAt: string; + perCaseTimeoutMs: number; + results: BenchmarkRow[]; +}; + +const benchmarkData = benchmarkPayload as BenchmarkPayload; + +const renderContent: NonNullable = (line) => { + return `🧪 ${line.replace(/const/g, 'CONST').replace(/process/g, 'PROCESS')}`; +}; + +function formatDuration(ms: number | null) { + return ms == null ? 'N/A' : `${ms.toFixed(1)} ms`; +} + +function formatFps(fps: number | null) { + return fps == null ? 'N/A' : `${fps.toFixed(1)} FPS`; +} + +function formatMemory(bytes: number | null) { + if (bytes == null) { + return 'N/A'; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export default function DemoPage() { const [state, setState] = useState(INITIAL_STATE); const [isPreparing, setIsPreparing] = useState(false); @@ -27,6 +75,8 @@ export default function DemoPage() { commitTime: null, totalTime: null, }); + const [lastClickedLineId, setLastClickedLineId] = useState(null); + const diffViewerRef = useRef(null); const interactionStartRef = useRef(null); const prepareEndRef = useRef(null); @@ -38,6 +88,32 @@ export default function DemoPage() { return generateDiffText(initialDataset.lines); }); + const benchmarkSummary = useMemo(() => { + const findEntry = (lib: string, lines: number) => + benchmarkData.results.find((item) => item.lib === lib && item.lines === lines); + + const target10k = findEntry('react-virtualized-diff', 10000); + const target100k = findEntry('react-virtualized-diff', 100000); + const compare100k = findEntry('react-diff-view', 100000); + + const timeoutCount = benchmarkData.results.filter((item) => item.status === 'timeout').length; + + let memoryRatio: string | null = null; + if (target100k?.memoryBytes && compare100k?.memoryBytes) { + memoryRatio = `${(compare100k.memoryBytes / target100k.memoryBytes).toFixed(1)}x`; + } + + return { + target10k, + target100k, + compare100k, + timeoutCount, + memoryRatio, + generatedAt: new Date(benchmarkData.generatedAt).toLocaleString(), + timeoutMs: benchmarkData.perCaseTimeoutMs, + }; + }, []); + useEffect(() => { let cancelled = false; const prepareStart = performance.now(); @@ -97,7 +173,7 @@ export default function DemoPage() { commitTime: prepareEnd === null ? null : commitMoment - prepareEnd, totalTime: commitMoment - interactionStart, })); - }, [diffData, state.contextLines, state.height, isPreparing]); + }, [diffData, state, isPreparing]); useEffect(() => { if (interactionStartRef.current === null) { @@ -105,6 +181,38 @@ export default function DemoPage() { } }, []); + const viewerProps: DiffViewerProps = { + height: state.height, + splitView: state.splitView, + showDiffOnly: state.showDiffOnly, + contextLines: state.contextLines, + extraLinesSurroundingDiff: state.contextLines, + hideLineNumbers: state.hideLineNumbers, + renderContent: state.enableRenderContent ? renderContent : undefined, + highlightLines: state.enableHighlight ? ['R-1', 'R-24', 'L-98', 'R-212'] : undefined, + onLineNumberClick: (lineId) => { + setLastClickedLineId(lineId); + }, + compareMethod: state.compareMethod, + disableWordDiff: state.disableWordDiff, + useDarkTheme: state.useDarkTheme, + linesOffset: state.linesOffset, + leftTitle: 'Original', + rightTitle: 'Modified', + codeFoldMessageRenderer: state.useCustomFoldRenderer + ? ({ hiddenCount, expanded }) => + expanded ? 'Collapse unchanged block' : `Show ${hiddenCount} hidden lines (custom)` + : undefined, + }; + + if (state.useCompatApi) { + viewerProps.oldValue = diffData.oldText; + viewerProps.newValue = diffData.newText; + } else { + viewerProps.original = diffData.oldText; + viewerProps.modified = diffData.newText; + } + return (
@@ -113,11 +221,41 @@ export default function DemoPage() {

Interactive demo

- Switch dataset size, viewport height, and context settings to see how the diff viewer - behaves under different conditions. + Switch dataset size and toggle compatibility APIs (`oldValue/newValue`, `splitView`, + `showDiffOnly`, `renderContent`, `highlightLines`, `compareMethod`, `useDarkTheme`) to verify behavior quickly.

+
+
+

Benchmark snapshot

+

Loaded from benchmark-results/results.json · Updated at {benchmarkSummary.generatedAt}

+
+ +
+
+ 10k lines + {formatFps(benchmarkSummary.target10k?.averageFps ?? null)} + {formatDuration(benchmarkSummary.target10k?.initialRenderTimeMs ?? null)} +
+
+ 100k lines memory + {formatMemory(benchmarkSummary.target100k?.memoryBytes ?? null)} + react-virtualized-diff +
+
+ Memory advantage + {benchmarkSummary.memoryRatio ?? 'N/A'} + vs react-diff-view @100k +
+
+ Timeout cases + {benchmarkSummary.timeoutCount} + {benchmarkSummary.timeoutMs} ms per case +
+
+
+
@@ -138,16 +287,36 @@ export default function DemoPage() {

Current dataset: {dataset.label}

+

+ Last clicked line id: {lastClickedLineId ?? 'None'} +

+ {state.enableRenderContent ? ( +

+ `renderContent` active: each line is prefixed with 🧪. +

+ ) : null} + {state.enableHighlight ? ( +

+ Highlight preset active: R-1, R-24, L-98, R-212. +

+ ) : null} +

+ Compare: {state.compareMethod} · Word diff:{' '} + {state.disableWordDiff ? 'off' : 'on'} · Offset:{' '} + {state.linesOffset} +

+
- + {isPreparing ? (
diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css index df85683..2b60ee9 100644 --- a/apps/demo/src/styles.css +++ b/apps/demo/src/styles.css @@ -255,6 +255,66 @@ select { padding-bottom: 40px; } + +.benchmark-strip { + margin-bottom: 20px; + border: 1px solid rgba(124, 156, 255, 0.24); + border-radius: 18px; + background: linear-gradient(140deg, rgba(92, 114, 255, 0.16), rgba(9, 16, 29, 0.82)); + box-shadow: var(--shadow); + padding: 16px; +} + +.benchmark-strip__header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.benchmark-strip__header h2 { + margin: 0; + font-size: 20px; +} + +.benchmark-strip__header p { + margin: 0; + color: var(--text-soft); + font-size: 14px; +} + +.benchmark-strip__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.benchmark-pill { + padding: 14px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(2, 6, 23, 0.58); + display: flex; + flex-direction: column; + gap: 4px; +} + +.benchmark-pill span { + color: var(--text-soft); + font-size: 13px; +} + +.benchmark-pill strong { + font-size: 24px; + line-height: 1.2; +} + +.benchmark-pill small { + color: var(--text-soft); +} + .demo-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); @@ -293,10 +353,12 @@ select { .demo-viewer-card { overflow: hidden; + background: linear-gradient(180deg, rgba(16, 24, 48, 0.95), rgba(9, 16, 29, 0.9)); } .demo-viewer-card__header { padding: 20px 20px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); } .demo-viewer-card__header h2 { @@ -310,6 +372,7 @@ select { .demo-viewer-card__body { padding: 20px; + background: rgba(2, 6, 23, 0.22); } .metrics-bar { @@ -336,6 +399,7 @@ select { } @media (max-width: 960px) { + .benchmark-strip__grid, .demo-layout, .feature-grid, .content-grid, @@ -400,3 +464,17 @@ select { transform: rotate(360deg); } } + +.toggle-row { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-soft); + margin-bottom: 10px; + font-size: 14px; +} + +.toggle-row input { + width: 16px; + height: 16px; +} diff --git a/benchmark-results/results.json b/benchmark-results/results.json index 6fcf8dd..0a1fbba 100644 --- a/benchmark-results/results.json +++ b/benchmark-results/results.json @@ -1,162 +1,166 @@ -[ - { - "lib": "react-virtualized-diff", - "lines": 1000, - "initialRenderTimeMs": 135.19999998807907, - "averageFps": 60.4, - "memoryBytes": 10000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-virtualized-diff", - "lines": 10000, - "initialRenderTimeMs": 187.19999998807907, - "averageFps": 60.4, - "memoryBytes": 10000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-virtualized-diff", - "lines": 50000, - "initialRenderTimeMs": 2961.699999988079, - "averageFps": 60.4, - "memoryBytes": 24500000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-virtualized-diff", - "lines": 100000, - "initialRenderTimeMs": 15242.300000011921, - "averageFps": 60.4, - "memoryBytes": 148000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-viewer", - "lines": 1000, - "initialRenderTimeMs": 152.5, - "averageFps": 60.4, - "memoryBytes": 11900000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-viewer", - "lines": 10000, - "initialRenderTimeMs": 1316, - "averageFps": 56.8, - "memoryBytes": 68000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-viewer", - "lines": 50000, - "initialRenderTimeMs": null, - "averageFps": null, - "memoryBytes": null, - "userAgent": null, - "status": "timeout", - "note": "Did not finish within 60000 ms" - }, - { - "lib": "react-diff-viewer", - "lines": 100000, - "initialRenderTimeMs": null, - "averageFps": null, - "memoryBytes": null, - "userAgent": null, - "status": "timeout", - "note": "Did not finish within 60000 ms" - }, - { - "lib": "react-diff-viewer-continued", - "lines": 1000, - "initialRenderTimeMs": 208.59999999403954, - "averageFps": 60.4, - "memoryBytes": 11900000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-viewer-continued", - "lines": 10000, - "initialRenderTimeMs": 1309.8999999761581, - "averageFps": 58.8, - "memoryBytes": 68000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-viewer-continued", - "lines": 50000, - "initialRenderTimeMs": null, - "averageFps": null, - "memoryBytes": null, - "userAgent": null, - "status": "timeout", - "note": "Did not finish within 60000 ms" - }, - { - "lib": "react-diff-viewer-continued", - "lines": 100000, - "initialRenderTimeMs": null, - "averageFps": null, - "memoryBytes": null, - "userAgent": null, - "status": "timeout", - "note": "Did not finish within 60000 ms" - }, - { - "lib": "react-diff-view", - "lines": 1000, - "initialRenderTimeMs": 265.7999999821186, - "averageFps": 60.4, - "memoryBytes": 19300000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-view", - "lines": 10000, - "initialRenderTimeMs": 1438.5, - "averageFps": 60.4, - "memoryBytes": 139000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-view", - "lines": 50000, - "initialRenderTimeMs": 7487.699999988079, - "averageFps": 13.2, - "memoryBytes": 662000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - }, - { - "lib": "react-diff-view", - "lines": 100000, - "initialRenderTimeMs": 16738.40000000596, - "averageFps": 5.6, - "memoryBytes": 1360000000, - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", - "status": "ok", - "note": null - } -] +{ + "generatedAt": "2026-04-08T06:45:43.686Z", + "perCaseTimeoutMs": 60000, + "results": [ + { + "lib": "react-virtualized-diff", + "lines": 1000, + "initialRenderTimeMs": 128.09999999403954, + "averageFps": 60.4, + "memoryBytes": 10000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-virtualized-diff", + "lines": 10000, + "initialRenderTimeMs": 127, + "averageFps": 60.8, + "memoryBytes": 10000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-virtualized-diff", + "lines": 50000, + "initialRenderTimeMs": 1536.2000000178814, + "averageFps": 60.4, + "memoryBytes": 24500000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-virtualized-diff", + "lines": 100000, + "initialRenderTimeMs": 7490.0999999940395, + "averageFps": 60.4, + "memoryBytes": 109000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-viewer", + "lines": 1000, + "initialRenderTimeMs": 155.40000000596046, + "averageFps": 60.4, + "memoryBytes": 11900000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-viewer", + "lines": 10000, + "initialRenderTimeMs": 1307.5, + "averageFps": 57.6, + "memoryBytes": 68000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-viewer", + "lines": 50000, + "initialRenderTimeMs": null, + "averageFps": null, + "memoryBytes": null, + "userAgent": null, + "status": "timeout", + "note": "Did not finish within 60000 ms" + }, + { + "lib": "react-diff-viewer", + "lines": 100000, + "initialRenderTimeMs": null, + "averageFps": null, + "memoryBytes": null, + "userAgent": null, + "status": "timeout", + "note": "Did not finish within 60000 ms" + }, + { + "lib": "react-diff-viewer-continued", + "lines": 1000, + "initialRenderTimeMs": 213.09999999403954, + "averageFps": 60.4, + "memoryBytes": 11900000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-viewer-continued", + "lines": 10000, + "initialRenderTimeMs": 1303.9000000059605, + "averageFps": 58, + "memoryBytes": 68000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-viewer-continued", + "lines": 50000, + "initialRenderTimeMs": null, + "averageFps": null, + "memoryBytes": null, + "userAgent": null, + "status": "timeout", + "note": "Did not finish within 60000 ms" + }, + { + "lib": "react-diff-viewer-continued", + "lines": 100000, + "initialRenderTimeMs": null, + "averageFps": null, + "memoryBytes": null, + "userAgent": null, + "status": "timeout", + "note": "Did not finish within 60000 ms" + }, + { + "lib": "react-diff-view", + "lines": 1000, + "initialRenderTimeMs": 289, + "averageFps": 60.4, + "memoryBytes": 19300000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-view", + "lines": 10000, + "initialRenderTimeMs": 1434.4000000059605, + "averageFps": 60.4, + "memoryBytes": 139000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-view", + "lines": 50000, + "initialRenderTimeMs": 7613.4000000059605, + "averageFps": 12.8, + "memoryBytes": 662000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + }, + { + "lib": "react-diff-view", + "lines": 100000, + "initialRenderTimeMs": 16987.59999999404, + "averageFps": 6, + "memoryBytes": 1360000000, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.7727.15 Safari/537.36", + "status": "ok", + "note": null + } + ] +} diff --git a/benchmark-results/results.md b/benchmark-results/results.md index 43d1085..c7cc0aa 100644 --- a/benchmark-results/results.md +++ b/benchmark-results/results.md @@ -1,6 +1,6 @@ # Benchmark Results -Generated at: 2026-04-07T05:08:36.114Z +Generated at: 2026-04-08T06:45:43.686Z Per-case timeout: 60000 ms @@ -8,20 +8,20 @@ Per-case timeout: 60000 ms | Library | Lines | Status | Initial Render (ms) | FPS | Memory | Note | | --- | ---: | --- | ---: | ---: | ---: | --- | -| react-virtualized-diff | 1,000 | ok | 135.20 | 60.40 | 9.5 MB | | -| react-virtualized-diff | 10,000 | ok | 187.20 | 60.40 | 9.5 MB | | -| react-virtualized-diff | 50,000 | ok | 2961.70 | 60.40 | 23.4 MB | | -| react-virtualized-diff | 100,000 | ok | 15242.30 | 60.40 | 141.1 MB | | -| react-diff-viewer | 1,000 | ok | 152.50 | 60.40 | 11.3 MB | | -| react-diff-viewer | 10,000 | ok | 1316.00 | 56.80 | 64.8 MB | | +| react-virtualized-diff | 1,000 | ok | 128.10 | 60.40 | 9.5 MB | | +| react-virtualized-diff | 10,000 | ok | 127.00 | 60.80 | 9.5 MB | | +| react-virtualized-diff | 50,000 | ok | 1536.20 | 60.40 | 23.4 MB | | +| react-virtualized-diff | 100,000 | ok | 7490.10 | 60.40 | 104.0 MB | | +| react-diff-viewer | 1,000 | ok | 155.40 | 60.40 | 11.3 MB | | +| react-diff-viewer | 10,000 | ok | 1307.50 | 57.60 | 64.8 MB | | | react-diff-viewer | 50,000 | timeout | N/A | N/A | N/A | Did not finish within 60000 ms | | react-diff-viewer | 100,000 | timeout | N/A | N/A | N/A | Did not finish within 60000 ms | -| react-diff-viewer-continued | 1,000 | ok | 208.60 | 60.40 | 11.3 MB | | -| react-diff-viewer-continued | 10,000 | ok | 1309.90 | 58.80 | 64.8 MB | | +| react-diff-viewer-continued | 1,000 | ok | 213.10 | 60.40 | 11.3 MB | | +| react-diff-viewer-continued | 10,000 | ok | 1303.90 | 58.00 | 64.8 MB | | | react-diff-viewer-continued | 50,000 | timeout | N/A | N/A | N/A | Did not finish within 60000 ms | | react-diff-viewer-continued | 100,000 | timeout | N/A | N/A | N/A | Did not finish within 60000 ms | -| react-diff-view | 1,000 | ok | 265.80 | 60.40 | 18.4 MB | | -| react-diff-view | 10,000 | ok | 1438.50 | 60.40 | 132.6 MB | | -| react-diff-view | 50,000 | ok | 7487.70 | 13.20 | 631.3 MB | | -| react-diff-view | 100,000 | ok | 16738.40 | 5.60 | 1297.0 MB | | +| react-diff-view | 1,000 | ok | 289.00 | 60.40 | 18.4 MB | | +| react-diff-view | 10,000 | ok | 1434.40 | 60.40 | 132.6 MB | | +| react-diff-view | 50,000 | ok | 7613.40 | 12.80 | 631.3 MB | | +| react-diff-view | 100,000 | ok | 16987.60 | 6.00 | 1297.0 MB | | diff --git a/packages/react/README.md b/packages/react/README.md index 6b80e5b..ec05cac 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -35,9 +35,27 @@ export function App() { ### `DiffViewer` props -- `original: string` -- `modified: string` +- `original?: string` +- `modified?: string` +- `oldValue?: string` (compatibility API) +- `newValue?: string` (compatibility API) +- `splitView?: boolean` (default `true`) +- `showDiffOnly?: boolean` (default `true`) - `contextLines?: number` (default `2`) +- `extraLinesSurroundingDiff?: number` (compatibility alias) +- `hideLineNumbers?: boolean` (default `false`) +- `highlightLines?: Array<'L-n' | 'R-n' | range>` +- `onLineNumberClick?: (lineId) => void` +- `renderContent?: (line: string) => ReactNode` +- `compareMethod?: "CHARS" | "WORDS" | "WORDS_WITH_SPACE" | "LINES" | "TRIMMED_LINES" | "SENTENCES" | "CSS"` +- `disableWordDiff?: boolean` +- `leftTitle?: ReactNode` +- `rightTitle?: ReactNode` +- `linesOffset?: number` (default `0`) +- `useDarkTheme?: boolean` +- `styles?: Partial` +- `codeFoldMessageRenderer?: ({ hiddenCount, expanded }) => ReactNode` +- `ref?.resetCodeBlocks(): void` - `height?: number | string` (default `500`) - `locale?: DiffViewerLocale` - `language?: string` (reserved for future use) diff --git a/packages/react/src/DiffViewer.tsx b/packages/react/src/DiffViewer.tsx index fff9559..57eb756 100644 --- a/packages/react/src/DiffViewer.tsx +++ b/packages/react/src/DiffViewer.tsx @@ -1,33 +1,350 @@ -import React, { useMemo, useState } from 'react'; +import React, { useImperativeHandle, useMemo, useState } from 'react'; import { Virtuoso } from 'react-virtuoso'; + +const VirtuosoComponent = Virtuoso as unknown as React.ComponentType; import { buildRenderItems, buildVisibleMap, computeDiffLines } from './diff'; import { defaultLocale } from './locale'; -import { getBackgroundColor, getPrefix } from './style'; -import type { DiffViewerProps } from './types'; +import { getPrefix } from './style'; +import type { + DiffLine, + DiffViewerProps, + DiffViewerStyles, + HighlightToken, + LineId, + WordChunk, + DiffViewerHandle, +} from './types'; + +const lightStyles: DiffViewerStyles = { + container: { + display: 'flex', + flexDirection: 'column', + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Consolas, Liberation Mono, Menlo, monospace', + border: '1px solid #e5e7eb', + borderRadius: 8, + overflow: 'hidden', + }, + titleRow: { + display: 'flex', + borderBottom: '1px solid #e5e7eb', + backgroundColor: '#f8fafc', + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + fontSize: 13, + fontWeight: 600, + color: '#374151', + }, + titleCell: { + width: '50%', + padding: '8px 12px', + borderRight: '1px solid #e5e7eb', + }, + line: { + display: 'flex', + borderBottom: '1px solid #f3f4f6', + }, + lineNumber: { + width: 48, + textAlign: 'right', + paddingRight: 12, + color: '#6b7280', + flexShrink: 0, + }, + marker: { + width: 24, + textAlign: 'center', + paddingRight: 12, + color: '#6b7280', + flexShrink: 0, + }, + code: { + margin: 0, + flex: 1, + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + }, + wordAdded: { + backgroundColor: '#acf2bd', + }, + wordRemoved: { + backgroundColor: '#ffdce0', + }, +}; + +const darkStyles: DiffViewerStyles = { + ...lightStyles, + container: { + ...lightStyles.container, + border: '1px solid #374151', + backgroundColor: '#111827', + color: '#e5e7eb', + }, + titleRow: { + ...lightStyles.titleRow, + backgroundColor: '#1f2937', + borderBottom: '1px solid #374151', + color: '#cbd5e1', + }, + titleCell: { + ...lightStyles.titleCell, + borderRight: '1px solid #374151', + }, + line: { + ...lightStyles.line, + borderBottom: '1px solid #1f2937', + }, + lineNumber: { + ...lightStyles.lineNumber, + color: '#94a3b8', + }, + marker: { + ...lightStyles.marker, + color: '#94a3b8', + }, + wordAdded: { + backgroundColor: '#144620', + }, + wordRemoved: { + backgroundColor: '#5b1a1a', + }, +}; + +function mergeStyles(useDarkTheme: boolean, styles?: Partial): DiffViewerStyles { + const base = useDarkTheme ? darkStyles : lightStyles; + + return { + container: { ...base.container, ...styles?.container }, + titleRow: { ...base.titleRow, ...styles?.titleRow }, + titleCell: { ...base.titleCell, ...styles?.titleCell }, + line: { ...base.line, ...styles?.line }, + lineNumber: { ...base.lineNumber, ...styles?.lineNumber }, + marker: { ...base.marker, ...styles?.marker }, + code: { ...base.code, ...styles?.code }, + wordAdded: { ...base.wordAdded, ...styles?.wordAdded }, + wordRemoved: { ...base.wordRemoved, ...styles?.wordRemoved }, + }; +} + +function parseHighlightLines(tokens: HighlightToken[] | undefined): Set { + const highlighted = new Set(); + + if (!tokens) { + return highlighted; + } + + tokens.forEach((token) => { + const single = token.match(/^([LR])-(\d+)$/); + if (single) { + highlighted.add(`${single[1]}-${single[2]}`); + return; + } + + const range = token.match(/^([LR])-(\d+)-(\d+)$/); + if (!range) { + return; + } + + const side = range[1]; + const start = Number(range[2]); + const end = Number(range[3]); + + if (Number.isNaN(start) || Number.isNaN(end) || start <= 0 || end <= 0) { + return; + } + + const low = Math.min(start, end); + const high = Math.max(start, end); + + for (let lineNumber = low; lineNumber <= high; lineNumber += 1) { + highlighted.add(`${side}-${lineNumber}`); + } + }); -export function DiffViewer({ + return highlighted; +} + +function applyLineOffset(lineNumber: number | null, linesOffset: number): number | null { + if (lineNumber === null) { + return null; + } + + return lineNumber + linesOffset; +} + +function getLineId(side: 'L' | 'R', lineNumber: number | null, linesOffset: number): LineId | null { + const adjustedLineNumber = applyLineOffset(lineNumber, linesOffset); + if (adjustedLineNumber === null) { + return null; + } + + return `${side}-${adjustedLineNumber}`; +} + +function renderLineNumber( + side: 'L' | 'R', + lineNumber: number | null, + hideLineNumbers: boolean, + highlightedLineIds: Set, + onLineNumberClick: ((lineId: LineId) => void) | undefined, + linesOffset: number, + mergedStyles: DiffViewerStyles, +): React.JSX.Element | null { + if (hideLineNumbers) { + return null; + } + + const lineId = getLineId(side, lineNumber, linesOffset); + const adjustedLineNumber = applyLineOffset(lineNumber, linesOffset); + const isHighlighted = lineId !== null && highlightedLineIds.has(lineId); + + if (lineId !== null && onLineNumberClick) { + return ( + + ); + } + + return ( +
+ {adjustedLineNumber ?? ''} +
+ ); +} + +function renderWordChunks(chunks: WordChunk[] | undefined, mergedStyles: DiffViewerStyles): React.ReactNode { + if (!chunks || chunks.length === 0) { + return null; + } + + return chunks.map((chunk, chunkIndex) => { + const style = + chunk.type === 'added' + ? mergedStyles.wordAdded + : chunk.type === 'removed' + ? mergedStyles.wordRemoved + : undefined; + + return ( + + {chunk.value} + + ); + }); +} + +function renderContentCell( + content: string, + renderContent: DiffViewerProps['renderContent'] | undefined, + chunks: WordChunk[] | undefined, + mergedStyles: DiffViewerStyles, +): React.ReactNode { + if (renderContent) { + return renderContent(content); + } + + const chunkContent = renderWordChunks(chunks, mergedStyles); + if (chunkContent !== null) { + return chunkContent; + } + + return content; +} + +function getCellBackground( + line: DiffLine, + sideVisible: boolean, + lineId: LineId | null, + highlightedLineIds: Set, + useDarkTheme: boolean, +): string { + if (lineId !== null && highlightedLineIds.has(lineId)) { + return useDarkTheme ? '#3d3413' : '#fff7cc'; + } + + if (!sideVisible) { + return useDarkTheme ? '#111827' : '#fff'; + } + + if (line.type === 'added') { + return useDarkTheme ? '#0f2a1a' : '#e6ffed'; + } + + if (line.type === 'removed') { + return useDarkTheme ? '#3a1515' : '#ffeef0'; + } + + return useDarkTheme ? '#111827' : '#fff'; +} + +export const DiffViewer = React.forwardRef(function DiffViewer({ original, modified, + oldValue, + newValue, contextLines = 2, + extraLinesSurroundingDiff, + showDiffOnly = true, + splitView = true, + hideLineNumbers = false, + highlightLines, + onLineNumberClick, + renderContent, + compareMethod = 'LINES', + disableWordDiff = false, + leftTitle, + rightTitle, + linesOffset = 0, + useDarkTheme = false, + styles, + codeFoldMessageRenderer, height = 500, locale, -}: DiffViewerProps): React.JSX.Element { - const [expandedBlocks, setExpandedBlocks] = useState>( - {}, - ); +}, ref): React.JSX.Element { + const [expandedBlocks, setExpandedBlocks] = useState>({}); const mergedLocale = { ...defaultLocale, ...locale, }; + const mergedStyles = useMemo( + () => mergeStyles(useDarkTheme, styles), + [styles, useDarkTheme], + ); + + const sourceOriginal = original ?? oldValue ?? ''; + const sourceModified = modified ?? newValue ?? ''; + const surroundingLines = extraLinesSurroundingDiff ?? contextLines; + + const highlightedLineIds = useMemo( + () => parseHighlightLines(highlightLines), + [highlightLines], + ); + const diffLinesData = useMemo(() => { - return computeDiffLines(original, modified); - }, [original, modified]); + return computeDiffLines(sourceOriginal, sourceModified, compareMethod, disableWordDiff); + }, [sourceOriginal, sourceModified, compareMethod, disableWordDiff]); const visibleMap = useMemo(() => { - return buildVisibleMap(diffLinesData, contextLines); - }, [diffLinesData, contextLines]); + return buildVisibleMap(diffLinesData, surroundingLines, showDiffOnly); + }, [diffLinesData, showDiffOnly, surroundingLines]); const renderItems = useMemo(() => { return buildRenderItems(diffLinesData, visibleMap, expandedBlocks); @@ -40,77 +357,153 @@ export function DiffViewer({ })); } + useImperativeHandle( + ref, + () => ({ + resetCodeBlocks: () => { + setExpandedBlocks({}); + }, + }), + [], + ); + return ( -
- + {(leftTitle !== undefined || rightTitle !== undefined) && splitView ? ( +
+
{leftTitle}
+
{rightTitle}
+
+ ) : null} + + { + itemContent={(index: number) => { const item = renderItems[index]; if (item.type === 'line') { const { line } = item; + const leftId = getLineId('L', line.leftLineNumber, linesOffset); + const rightId = getLineId('R', line.rightLineNumber, linesOffset); - return ( -
+ if (!splitView) { + const inlineContent = + line.rightLineNumber !== null ? line.rightContent : line.leftContent; + const unifiedHighlight = + (leftId !== null && highlightedLineIds.has(leftId)) || + (rightId !== null && highlightedLineIds.has(rightId)); + const inlineChunks = line.rightWordChunks ?? line.leftWordChunks; + + return (
+ {hideLineNumbers ? null : ( +
+ {renderLineNumber( + 'L', + line.leftLineNumber, + false, + highlightedLineIds, + onLineNumberClick, + linesOffset, + mergedStyles, + )} + {renderLineNumber( + 'R', + line.rightLineNumber, + false, + highlightedLineIds, + onLineNumberClick, + linesOffset, + mergedStyles, + )} +
+ )} +
- {line.leftLineNumber ?? ''} -
-
- {getPrefix(line.type, line.leftLineNumber !== null)} + {getPrefix( + line.type, + line.leftLineNumber !== null || line.rightLineNumber !== null, + )}
+
-                    {line.leftContent}
+                    {renderContentCell(inlineContent, renderContent, inlineChunks, mergedStyles)}
+                  
+
+ ); + } + + return ( +
+
+ {renderLineNumber( + 'L', + line.leftLineNumber, + hideLineNumbers, + highlightedLineIds, + onLineNumberClick, + linesOffset, + mergedStyles, + )} +
{getPrefix(line.type, line.leftLineNumber !== null)}
+
+                    {renderContentCell(
+                      line.leftContent,
+                      renderContent,
+                      line.leftWordChunks,
+                      mergedStyles,
+                    )}
                   
@@ -120,43 +513,32 @@ export function DiffViewer({ display: 'flex', padding: '4px 8px', boxSizing: 'border-box', - backgroundColor: getBackgroundColor( - line.type, + backgroundColor: getCellBackground( + line, line.rightLineNumber !== null, + rightId, + highlightedLineIds, + useDarkTheme, ), }} > -
- {line.rightLineNumber ?? ''} -
-
- {getPrefix(line.type, line.rightLineNumber !== null)} -
-
-                    {line.rightContent}
+                  {renderLineNumber(
+                    'R',
+                    line.rightLineNumber,
+                    hideLineNumbers,
+                    highlightedLineIds,
+                    onLineNumberClick,
+                    linesOffset,
+                    mergedStyles,
+                  )}
+                  
{getPrefix(line.type, line.rightLineNumber !== null)}
+
+                    {renderContentCell(
+                      line.rightContent,
+                      renderContent,
+                      line.rightWordChunks,
+                      mergedStyles,
+                    )}
                   
@@ -170,21 +552,26 @@ export function DiffViewer({ style={{ width: '100%', border: 0, - borderBottom: '1px solid #e5e7eb', - backgroundColor: '#f9fafb', + borderBottom: useDarkTheme ? '1px solid #374151' : '1px solid #e5e7eb', + backgroundColor: useDarkTheme ? '#1f2937' : '#f9fafb', padding: '8px 12px', cursor: 'pointer', - color: '#374151', + color: useDarkTheme ? '#cbd5e1' : '#374151', fontStyle: 'italic', }} > - {item.expanded - ? mergedLocale.collapse - : mergedLocale.showMoreLines(item.hiddenCount)} + {codeFoldMessageRenderer + ? codeFoldMessageRenderer({ + hiddenCount: item.hiddenCount, + expanded: item.expanded, + }) + : item.expanded + ? mergedLocale.collapse + : mergedLocale.showMoreLines(item.hiddenCount)} ); }} />
); -} \ No newline at end of file +}); diff --git a/packages/react/src/diff.ts b/packages/react/src/diff.ts index b21b2e2..dfeebce 100644 --- a/packages/react/src/diff.ts +++ b/packages/react/src/diff.ts @@ -1,23 +1,188 @@ -import { diffLines } from 'diff'; -import type { DiffLine, RenderItem } from './types'; +import { + diffChars, + diffCss, + diffLines, + diffSentences, + diffTrimmedLines, + diffWords, + diffWordsWithSpace, +} from 'diff'; +import type { CompareMethod, DiffLine, RenderItem, WordChunk } from './types'; + +type DiffPart = { value: string; added?: boolean; removed?: boolean }; +type DiffFn = (oldValue: string, newValue: string) => DiffPart[]; + + +const DIFF_CACHE_LIMIT = 12; +const WORD_DIFF_LINE_LIMIT = 4000; +const diffCache = new Map(); + +function buildCacheKey( + original: string, + modified: string, + compareMethod: CompareMethod, + disableWordDiff: boolean, +): string { + const originalHead = original.slice(0, 80); + const originalTail = original.slice(-80); + const modifiedHead = modified.slice(0, 80); + const modifiedTail = modified.slice(-80); + + return [ + compareMethod, + disableWordDiff ? '1' : '0', + original.length, + modified.length, + originalHead, + originalTail, + modifiedHead, + modifiedTail, + ].join('|'); +} + +function readDiffCache(key: string): DiffLine[] | undefined { + const cached = diffCache.get(key); + if (!cached) { + return undefined; + } + + diffCache.delete(key); + diffCache.set(key, cached); + return cached; +} + +function writeDiffCache(key: string, value: DiffLine[]): void { + if (diffCache.has(key)) { + diffCache.delete(key); + } + + diffCache.set(key, value); + + if (diffCache.size > DIFF_CACHE_LIMIT) { + const oldestKey = diffCache.keys().next().value; + if (oldestKey) { + diffCache.delete(oldestKey); + } + } +} + +function pickCompareMethod(compareMethod: CompareMethod): DiffFn { + switch (compareMethod) { + case 'CHARS': + return diffChars as DiffFn; + case 'WORDS': + return diffWords as DiffFn; + case 'WORDS_WITH_SPACE': + return diffWordsWithSpace as DiffFn; + case 'TRIMMED_LINES': + return diffTrimmedLines as DiffFn; + case 'SENTENCES': + return diffSentences as DiffFn; + case 'CSS': + return diffCss as DiffFn; + case 'LINES': + default: + return diffLines as DiffFn; + } +} + +function computeWordChunks(left: string, right: string): { left: WordChunk[]; right: WordChunk[] } { + const result = diffWordsWithSpace(left, right); + const leftChunks: WordChunk[] = []; + const rightChunks: WordChunk[] = []; + + result.forEach((part) => { + if (part.added) { + rightChunks.push({ value: part.value, type: 'added' }); + return; + } + + if (part.removed) { + leftChunks.push({ value: part.value, type: 'removed' }); + return; + } + + leftChunks.push({ value: part.value, type: 'unchanged' }); + rightChunks.push({ value: part.value, type: 'unchanged' }); + }); + + return { left: leftChunks, right: rightChunks }; +} + +function attachWordChunks(lines: DiffLine[]): DiffLine[] { + const nextLines = [...lines]; + + for (let index = 0; index < nextLines.length; index += 1) { + if (nextLines[index].type !== 'removed') { + continue; + } + + let removedCursor = index; + let addedCursor = index; + + while (removedCursor < nextLines.length && nextLines[removedCursor].type === 'removed') { + removedCursor += 1; + } + + while (addedCursor < nextLines.length && nextLines[addedCursor].type !== 'added') { + if (nextLines[addedCursor].type === 'unchanged') { + break; + } + addedCursor += 1; + } + + if (addedCursor >= nextLines.length || nextLines[addedCursor].type !== 'added') { + continue; + } + + const removedCount = removedCursor - index; + let addedEnd = addedCursor; + + while (addedEnd < nextLines.length && nextLines[addedEnd].type === 'added') { + addedEnd += 1; + } + + const pairCount = Math.min(removedCount, addedEnd - addedCursor); + + for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) { + const removedLine = nextLines[index + pairIndex]; + const addedLine = nextLines[addedCursor + pairIndex]; + const chunks = computeWordChunks(removedLine.leftContent, addedLine.rightContent); + + removedLine.leftWordChunks = chunks.left; + addedLine.rightWordChunks = chunks.right; + } + } + + return nextLines; +} export function computeDiffLines( original: string, modified: string, + compareMethod: CompareMethod = 'LINES', + disableWordDiff = false, ): DiffLine[] { - const diffResult = diffLines(original, modified); + const cacheKey = buildCacheKey(original, modified, compareMethod, disableWordDiff); + const cached = readDiffCache(cacheKey); + if (cached) { + return cached; + } + + const method = pickCompareMethod(compareMethod); + const diffResult: DiffPart[] = method(original, modified); const diffLinesArray: DiffLine[] = []; let leftLineNum = 1; let rightLineNum = 1; - diffResult.forEach((part) => { + diffResult.forEach((part: DiffPart) => { const lines = part.value .split('\n') - .filter((line, index, arr) => !(index === arr.length - 1 && line === '')); + .filter((line: string, index: number, arr: string[]) => !(index === arr.length - 1 && line === '')); if (part.added) { - lines.forEach((line) => { + lines.forEach((line: string) => { diffLinesArray.push({ leftLineNumber: null, rightLineNumber: rightLineNum, @@ -31,7 +196,7 @@ export function computeDiffLines( } if (part.removed) { - lines.forEach((line) => { + lines.forEach((line: string) => { diffLinesArray.push({ leftLineNumber: leftLineNum, rightLineNumber: null, @@ -57,14 +222,26 @@ export function computeDiffLines( }); }); - return diffLinesArray; + let result = diffLinesArray; + + if (!disableWordDiff && diffLinesArray.length <= WORD_DIFF_LINE_LIMIT) { + result = attachWordChunks(diffLinesArray); + } + + writeDiffCache(cacheKey, result); + return result; } export function buildVisibleMap( lines: DiffLine[], contextLines: number, + showDiffOnly: boolean, ): boolean[] { const total = lines.length; + if (!showDiffOnly) { + return new Array(total).fill(true); + } + const visible = new Array(total).fill(false); lines.forEach((line, index) => { @@ -78,6 +255,10 @@ export function buildVisibleMap( } }); + if (!visible.some(Boolean)) { + return new Array(total).fill(true); + } + return visible; } diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 4b04ead..691fa9f 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,11 +1,29 @@ +import type React from 'react'; + export type DiffType = 'added' | 'removed' | 'unchanged'; +export type CompareMethod = + | 'CHARS' + | 'WORDS' + | 'WORDS_WITH_SPACE' + | 'LINES' + | 'TRIMMED_LINES' + | 'SENTENCES' + | 'CSS'; + +export interface WordChunk { + value: string; + type: DiffType; +} + export interface DiffLine { leftLineNumber: number | null; rightLineNumber: number | null; leftContent: string; rightContent: string; type: DiffType; + leftWordChunks?: WordChunk[]; + rightWordChunks?: WordChunk[]; } export type RenderItem = @@ -23,11 +41,56 @@ export interface DiffViewerLocale { showMoreLines?: (count: number) => string; } +export interface DiffViewerStyles { + container: React.CSSProperties; + titleRow: React.CSSProperties; + titleCell: React.CSSProperties; + line: React.CSSProperties; + lineNumber: React.CSSProperties; + marker: React.CSSProperties; + code: React.CSSProperties; + wordAdded: React.CSSProperties; + wordRemoved: React.CSSProperties; +} + +export type LineId = `L-${number}` | `R-${number}`; + +export type HighlightToken = LineId | `${LineId}-${number}`; + +export type RenderContent = (source: string) => React.ReactNode; + + +export interface DiffViewerHandle { + resetCodeBlocks: () => void; +} + +export type CodeFoldMessageRenderer = (params: { + hiddenCount: number; + expanded: boolean; +}) => React.ReactNode; + export interface DiffViewerProps { - original: string; - modified: string; + original?: string; + modified?: string; + oldValue?: string; + newValue?: string; language?: string; contextLines?: number; + extraLinesSurroundingDiff?: number; + showDiffOnly?: boolean; + splitView?: boolean; + hideLineNumbers?: boolean; + highlightLines?: HighlightToken[]; + onLineNumberClick?: (lineId: LineId) => void; + renderContent?: RenderContent; + compareMethod?: CompareMethod; + disableWordDiff?: boolean; + leftTitle?: React.ReactNode; + rightTitle?: React.ReactNode; + linesOffset?: number; + useDarkTheme?: boolean; + styles?: Partial; + codeFoldMessageRenderer?: CodeFoldMessageRenderer; height?: number | string; locale?: DiffViewerLocale; } \ No newline at end of file diff --git a/scripts/run-benchmark.mjs b/scripts/run-benchmark.mjs index cade3a1..0769854 100644 --- a/scripts/run-benchmark.mjs +++ b/scripts/run-benchmark.mjs @@ -32,7 +32,8 @@ function formatBytes(bytes) { return `${mb.toFixed(1)} MB`; } -function toMarkdown(results) { +function toMarkdown(payload) { + const { generatedAt, perCaseTimeoutMs: timeoutMs, results } = payload; const header = '| Library | Lines | Status | Initial Render (ms) | FPS | Memory | Note |'; const sep = '| --- | ---: | --- | ---: | ---: | ---: | --- |'; const rows = results.map((item) => { @@ -43,9 +44,9 @@ function toMarkdown(results) { return [ '# Benchmark Results', '', - `Generated at: ${new Date().toISOString()}`, + `Generated at: ${generatedAt}`, '', - `Per-case timeout: ${perCaseTimeoutMs} ms`, + `Per-case timeout: ${timeoutMs} ms`, '', '> Memory usage comes from `performance.memory.usedJSHeapSize` and is only available in Chromium-based browsers.', '', @@ -115,6 +116,7 @@ try { await waitForServer(); const browser = await launchBrowserWithAutoInstall(); const context = await browser.newContext(); + const generatedAt = new Date().toISOString(); const results = []; for (const lib of libs) { @@ -161,8 +163,14 @@ try { const outDir = resolve('benchmark-results'); mkdirSync(outDir, { recursive: true }); - writeFileSync(resolve(outDir, 'results.json'), `${JSON.stringify(results, null, 2)}\n`, 'utf8'); - writeFileSync(resolve(outDir, 'results.md'), `${toMarkdown(results)}\n`, 'utf8'); + const payload = { + generatedAt, + perCaseTimeoutMs, + results, + }; + + writeFileSync(resolve(outDir, 'results.json'), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + writeFileSync(resolve(outDir, 'results.md'), `${toMarkdown(payload)}\n`, 'utf8'); const timeoutCount = results.filter((item) => item.status === 'timeout').length; console.log(`Benchmark complete. Wrote ${results.length} records to benchmark-results/. Timeouts: ${timeoutCount}.`);