react-diff-viewer-continued 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,12 @@
8
8
  "Bash(pnpm test:*)",
9
9
  "Bash(CI=1 pnpm test:*)",
10
10
  "Bash(CI=1 npm test:*)",
11
- "Bash(pnpm remove:*)"
11
+ "Bash(pnpm remove:*)",
12
+ "Bash(npm test:*)",
13
+ "Bash(npx tsc:*)",
14
+ "Bash(ls:*)",
15
+ "Bash(pnpm run build:*)",
16
+ "Bash(pnpm exec vitest:*)"
12
17
  ],
13
18
  "deny": [],
14
19
  "ask": []
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.1 (2026-02-07)
4
+
5
+ ### Bug Fixes
6
+
7
+ - fix problem with missing worker in compiled version
8
+
3
9
  ## 4.1.0 (2026-02-04)
4
10
 
5
11
  ### Features
@@ -490,19 +490,29 @@ const computeLineInformation = (oldString, newString, disableWordDiff = false, l
490
490
  * @returns Promise<ComputedLineInformation> - Resolves with line-by-line diff data from the worker.
491
491
  */
492
492
  const computeLineInformationWorker = (oldString, newString, disableWordDiff = false, lineCompareMethod = DiffMethod.CHARS, linesOffset = 0, showLines = [], deferWordDiff = false) => {
493
+ const fallback = () => computeLineInformation(oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff);
493
494
  // Fall back to synchronous computation if Worker is not available (e.g., in Node.js/test environments)
494
495
  if (typeof Worker === 'undefined') {
495
- return Promise.resolve(computeLineInformation(oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff));
496
+ return Promise.resolve(fallback());
496
497
  }
497
- return new Promise((resolve, reject) => {
498
- const worker = new Worker(new URL('./computeWorker.ts', import.meta.url), { type: 'module' });
498
+ return new Promise((resolve) => {
499
+ let worker;
500
+ try {
501
+ worker = new Worker(new URL('./computeWorker.js', import.meta.url), { type: 'module' });
502
+ }
503
+ catch (_a) {
504
+ // Worker instantiation failed - fall back to synchronous computation
505
+ resolve(fallback());
506
+ return;
507
+ }
499
508
  worker.onmessage = (e) => {
500
509
  resolve(e.data);
501
510
  worker.terminate();
502
511
  };
503
- worker.onerror = (err) => {
504
- reject(err);
512
+ worker.onerror = () => {
513
+ // Worker error - fall back to synchronous computation
505
514
  worker.terminate();
515
+ resolve(fallback());
506
516
  };
507
517
  worker.postMessage({ oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff });
508
518
  });
@@ -65,6 +65,10 @@ export interface ReactDiffViewerProps {
65
65
  * Hide the summary bar (expand/collapse button, change count, filename)
66
66
  */
67
67
  hideSummary?: boolean;
68
+ /**
69
+ * Show debug overlay with virtualization info (for development)
70
+ */
71
+ showDebugInfo?: boolean;
68
72
  }
69
73
  export interface ReactDiffViewerState {
70
74
  expandedBlocks?: number[];
@@ -73,10 +77,17 @@ export interface ReactDiffViewerState {
73
77
  computedDiffResult: Record<string, ComputedDiffResult>;
74
78
  isLoading: boolean;
75
79
  visibleStartRow: number;
80
+ contentColumnWidth: number | null;
81
+ charWidth: number | null;
82
+ cumulativeOffsets: number[] | null;
76
83
  }
77
84
  declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiffViewerState> {
78
85
  private styles;
79
86
  private wordDiffCache;
87
+ private contentColumnRef;
88
+ private charMeasureRef;
89
+ private stickyHeaderRef;
90
+ private resizeObserver;
80
91
  static defaultProps: ReactDiffViewerProps;
81
92
  constructor(props: ReactDiffViewerProps);
82
93
  /**
@@ -94,6 +105,38 @@ declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiff
94
105
  * this value is used to expand/fold unmodified code.
95
106
  */
96
107
  private onBlockExpand;
108
+ /**
109
+ * Gets the height of the sticky header, if present.
110
+ */
111
+ private getStickyHeaderHeight;
112
+ /**
113
+ * Measures the width of a single character in the monospace font.
114
+ * Falls back to 7.2px if measurement fails.
115
+ */
116
+ private measureCharWidth;
117
+ /**
118
+ * Measures the available width for content in a content column.
119
+ * Falls back to estimating from container width if direct measurement fails.
120
+ */
121
+ private measureContentColumnWidth;
122
+ /**
123
+ * Gets the text length from a value that may be a string or DiffInformation array.
124
+ */
125
+ private getTextLength;
126
+ /**
127
+ * Builds cumulative vertical offsets for each line based on character count and column width.
128
+ * This allows accurate scroll position calculations with variable row heights.
129
+ */
130
+ private buildCumulativeOffsets;
131
+ /**
132
+ * Binary search to find the line index at a given scroll offset.
133
+ */
134
+ private findLineAtOffset;
135
+ /**
136
+ * Recalculates cumulative offsets based on current measurements.
137
+ * Called on resize and when blocks are expanded/collapsed.
138
+ */
139
+ private recalculateOffsets;
97
140
  /**
98
141
  * Computes final styles for the diff viewer. It combines the default styles with the user
99
142
  * supplied overrides. The computed styles are cached with performance in mind.
@@ -200,6 +243,7 @@ declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiff
200
243
  private renderDiff;
201
244
  componentDidUpdate(prevProps: ReactDiffViewerProps): void;
202
245
  componentDidMount(): void;
246
+ componentWillUnmount(): void;
203
247
  render: () => ReactElement;
204
248
  }
205
249
  export default DiffViewer;
@@ -161,6 +161,11 @@ class DiffViewer extends React.Component {
161
161
  super(props);
162
162
  // Cache for on-demand word diff computation
163
163
  this.wordDiffCache = new Map();
164
+ // Refs for measuring content column width and character width
165
+ this.contentColumnRef = React.createRef();
166
+ this.charMeasureRef = React.createRef();
167
+ this.stickyHeaderRef = React.createRef();
168
+ this.resizeObserver = null;
164
169
  /**
165
170
  * Computes word diff on-demand for a line, with caching.
166
171
  * This is used when word diff was deferred during initial computation.
@@ -211,8 +216,28 @@ class DiffViewer extends React.Component {
211
216
  this.onBlockExpand = (id) => {
212
217
  const prevState = this.state.expandedBlocks.slice();
213
218
  prevState.push(id);
214
- this.setState({
215
- expandedBlocks: prevState,
219
+ this.setState({ expandedBlocks: prevState }, () => this.recalculateOffsets());
220
+ };
221
+ /**
222
+ * Recalculates cumulative offsets based on current measurements.
223
+ * Called on resize and when blocks are expanded/collapsed.
224
+ */
225
+ this.recalculateOffsets = () => {
226
+ var _a;
227
+ if (!this.props.infiniteLoading)
228
+ return;
229
+ const columnWidth = this.measureContentColumnWidth();
230
+ const charWidth = this.measureCharWidth();
231
+ if (!columnWidth)
232
+ return;
233
+ const cacheKey = this.getMemoisedKey();
234
+ const { lineInformation, lineBlocks, blocks } = (_a = this.state.computedDiffResult[cacheKey]) !== null && _a !== void 0 ? _a : {};
235
+ if (!lineInformation)
236
+ return;
237
+ const offsets = this.buildCumulativeOffsets(lineInformation, lineBlocks, blocks, this.state.expandedBlocks, this.props.showDiffOnly, charWidth, columnWidth, this.props.splitView);
238
+ this.setState({ cumulativeOffsets: offsets, contentColumnWidth: columnWidth, charWidth }, () => {
239
+ // Force a scroll position update to recalculate visible rows with new offsets
240
+ this.onScroll();
216
241
  });
217
242
  };
218
243
  /**
@@ -379,7 +404,7 @@ class DiffViewer extends React.Component {
379
404
  [this.styles.diffRemoved]: removed,
380
405
  [this.styles.diffChanged]: changed,
381
406
  [this.styles.highlightedLine]: highlightLine,
382
- }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { className: cn(this.styles.content, {
407
+ }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { ref: prefix === LineNumberPrefix.LEFT && !this.state.cumulativeOffsets ? this.contentColumnRef : undefined, className: cn(this.styles.content, {
383
408
  [this.styles.emptyLine]: !content,
384
409
  [this.styles.diffAdded]: added,
385
410
  [this.styles.diffRemoved]: removed,
@@ -517,7 +542,13 @@ class DiffViewer extends React.Component {
517
542
  : Math.round(this.props.extraLinesSurroundingDiff);
518
543
  const { lineBlocks, blocks } = computeHiddenBlocks(lineInformation, diffLines, extraLines);
519
544
  this.state.computedDiffResult[cacheKey] = { lineInformation, lineBlocks, blocks };
520
- this.setState((prev) => (Object.assign(Object.assign({}, prev), { computedDiffResult: this.state.computedDiffResult, isLoading: false })));
545
+ this.setState((prev) => (Object.assign(Object.assign({}, prev), { computedDiffResult: this.state.computedDiffResult, isLoading: false })), () => {
546
+ // Trigger offset recalculation after diff is computed and rendered
547
+ // Use requestAnimationFrame to ensure DOM is ready for measurement
548
+ if (this.props.infiniteLoading) {
549
+ requestAnimationFrame(() => this.recalculateOffsets());
550
+ }
551
+ });
521
552
  });
522
553
  /**
523
554
  * Handles scroll events on the scrollable container.
@@ -528,7 +559,13 @@ class DiffViewer extends React.Component {
528
559
  const container = this.state.scrollableContainerRef.current;
529
560
  if (!container || !this.props.infiniteLoading)
530
561
  return;
531
- const newStartRow = Math.floor(container.scrollTop / DiffViewer.ESTIMATED_ROW_HEIGHT);
562
+ // Account for sticky header height in scroll calculations
563
+ const headerHeight = this.getStickyHeaderHeight();
564
+ const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
565
+ const { cumulativeOffsets } = this.state;
566
+ const newStartRow = cumulativeOffsets
567
+ ? this.findLineAtOffset(contentScrollTop, cumulativeOffsets)
568
+ : Math.floor(contentScrollTop / DiffViewer.ESTIMATED_ROW_HEIGHT);
532
569
  // Only update state if the start row changed (avoid unnecessary re-renders)
533
570
  if (newStartRow !== this.state.visibleStartRow) {
534
571
  this.setState({ visibleStartRow: newStartRow });
@@ -538,9 +575,9 @@ class DiffViewer extends React.Component {
538
575
  * Generates the entire diff view with virtualization support.
539
576
  */
540
577
  this.renderDiff = () => {
541
- var _a;
578
+ var _a, _b, _c, _d, _e, _f;
542
579
  const { splitView, infiniteLoading, showDiffOnly } = this.props;
543
- const { computedDiffResult, expandedBlocks, visibleStartRow, scrollableContainerRef } = this.state;
580
+ const { computedDiffResult, expandedBlocks, visibleStartRow, scrollableContainerRef, cumulativeOffsets } = this.state;
544
581
  const cacheKey = this.getMemoisedKey();
545
582
  const { lineInformation = [], lineBlocks = [], blocks = [] } = (_a = computedDiffResult[cacheKey]) !== null && _a !== void 0 ? _a : {};
546
583
  // Calculate visible range for virtualization
@@ -549,9 +586,32 @@ class DiffViewer extends React.Component {
549
586
  const buffer = 5; // render extra rows above/below viewport
550
587
  if (infiniteLoading && scrollableContainerRef.current) {
551
588
  const container = scrollableContainerRef.current;
552
- const viewportRows = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
553
- visibleRowStart = Math.max(0, visibleStartRow - buffer);
554
- visibleRowEnd = visibleStartRow + viewportRows + buffer;
589
+ // Account for sticky header height in scroll calculations
590
+ const headerHeight = this.getStickyHeaderHeight();
591
+ const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
592
+ if (cumulativeOffsets) {
593
+ // Variable height mode: use binary search to find visible range
594
+ const totalHeight = cumulativeOffsets[cumulativeOffsets.length - 1] || 0;
595
+ const lastRowIndex = cumulativeOffsets.length - 2;
596
+ visibleRowStart = Math.max(0, this.findLineAtOffset(contentScrollTop, cumulativeOffsets) - buffer);
597
+ visibleRowEnd = this.findLineAtOffset(contentScrollTop + container.clientHeight, cumulativeOffsets) + buffer;
598
+ // IMPORTANT: The calculated offsets may overestimate row heights (based on char count),
599
+ // but actual CSS rendering might produce shorter rows. To prevent empty space,
600
+ // ensure we render at least enough rows to fill the viewport using ESTIMATED_ROW_HEIGHT
601
+ // as a conservative minimum.
602
+ const minRowsToFillViewport = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
603
+ visibleRowEnd = Math.max(visibleRowEnd, visibleRowStart + minRowsToFillViewport + buffer);
604
+ // Also ensure we render all rows when near the bottom
605
+ if (contentScrollTop + container.clientHeight >= totalHeight - buffer * DiffViewer.ESTIMATED_ROW_HEIGHT) {
606
+ visibleRowEnd = lastRowIndex + buffer;
607
+ }
608
+ }
609
+ else {
610
+ // Fixed height fallback
611
+ const viewportRows = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
612
+ visibleRowStart = Math.max(0, visibleStartRow - buffer);
613
+ visibleRowEnd = visibleStartRow + viewportRows + buffer;
614
+ }
555
615
  }
556
616
  // First pass: build a map of lineIndex -> renderedRowIndex
557
617
  // This accounts for code folding where some lines don't render or render as fold indicators
@@ -589,6 +649,7 @@ class DiffViewer extends React.Component {
589
649
  const diffNodes = [];
590
650
  let topPadding = 0;
591
651
  let firstVisibleFound = false;
652
+ let lastRenderedRowIndex = -1;
592
653
  seenBlocks.clear();
593
654
  for (let lineIndex = 0; lineIndex < lineInformation.length; lineIndex++) {
594
655
  const line = lineInformation[lineIndex];
@@ -606,9 +667,13 @@ class DiffViewer extends React.Component {
606
667
  }
607
668
  // Calculate top padding from the first visible row
608
669
  if (!firstVisibleFound) {
609
- topPadding = rowIndex * DiffViewer.ESTIMATED_ROW_HEIGHT;
670
+ topPadding = cumulativeOffsets
671
+ ? cumulativeOffsets[rowIndex] || 0
672
+ : rowIndex * DiffViewer.ESTIMATED_ROW_HEIGHT;
610
673
  firstVisibleFound = true;
611
674
  }
675
+ // Track the last rendered row for bottom padding calculation
676
+ lastRenderedRowIndex = rowIndex;
612
677
  // Render the line
613
678
  if (showDiffOnly) {
614
679
  const blockIndex = lineBlocks[lineIndex];
@@ -628,15 +693,41 @@ class DiffViewer extends React.Component {
628
693
  ? this.renderSplitView(line, lineIndex)
629
694
  : this.renderInlineView(line, lineIndex));
630
695
  }
696
+ // Calculate total content height
697
+ const totalContentHeight = cumulativeOffsets
698
+ ? cumulativeOffsets[cumulativeOffsets.length - 1] || 0
699
+ : totalRenderedRows * DiffViewer.ESTIMATED_ROW_HEIGHT;
700
+ // Calculate bottom padding: space after the last rendered row
701
+ const bottomPadding = cumulativeOffsets && lastRenderedRowIndex >= 0
702
+ ? totalContentHeight - (cumulativeOffsets[lastRenderedRowIndex + 1] || totalContentHeight)
703
+ : 0;
631
704
  return {
632
705
  diffNodes,
633
706
  blocks,
634
707
  lineInformation,
635
708
  totalRenderedRows,
636
709
  topPadding,
710
+ bottomPadding,
711
+ totalContentHeight,
712
+ renderedCount: diffNodes.length,
713
+ // Debug info
714
+ debug: {
715
+ visibleRowStart,
716
+ visibleRowEnd,
717
+ totalRows: totalRenderedRows,
718
+ offsetsLength: (_b = cumulativeOffsets === null || cumulativeOffsets === void 0 ? void 0 : cumulativeOffsets.length) !== null && _b !== void 0 ? _b : 0,
719
+ renderedCount: diffNodes.length,
720
+ scrollTop: (_d = (_c = scrollableContainerRef.current) === null || _c === void 0 ? void 0 : _c.scrollTop) !== null && _d !== void 0 ? _d : 0,
721
+ headerHeight: this.getStickyHeaderHeight(),
722
+ contentScrollTop: scrollableContainerRef.current
723
+ ? Math.max(0, scrollableContainerRef.current.scrollTop - this.getStickyHeaderHeight())
724
+ : 0,
725
+ clientHeight: (_f = (_e = scrollableContainerRef.current) === null || _e === void 0 ? void 0 : _e.clientHeight) !== null && _f !== void 0 ? _f : 0,
726
+ }
637
727
  };
638
728
  };
639
729
  this.render = () => {
730
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
640
731
  const { oldValue, newValue, useDarkTheme, leftTitle, rightTitle, splitView, compareMethod, hideLineNumbers, nonce, } = this.props;
641
732
  if (typeof compareMethod === "string" &&
642
733
  compareMethod !== DiffMethod.JSON) {
@@ -690,9 +781,12 @@ class DiffViewer extends React.Component {
690
781
  overflowX: 'hidden',
691
782
  height: this.props.infiniteLoading.containerHeight
692
783
  } : {};
693
- const totalContentHeight = nodes.totalRenderedRows * DiffViewer.ESTIMATED_ROW_HEIGHT;
784
+ // Only apply noWrap when infiniteLoading is enabled but we don't have cumulative offsets yet
785
+ // Once offsets are calculated, we enable pre-wrap for proper text wrapping
786
+ const shouldNoWrap = !!this.props.infiniteLoading && !this.state.cumulativeOffsets;
694
787
  const tableElement = (_jsxs("table", { className: cn(this.styles.diffContainer, {
695
788
  [this.styles.splitView]: splitView,
789
+ [this.styles.noWrap]: shouldNoWrap,
696
790
  }), onMouseUp: () => {
697
791
  const elements = document.getElementsByClassName("right");
698
792
  for (let i = 0; i < elements.length; i++) {
@@ -705,13 +799,37 @@ class DiffViewer extends React.Component {
705
799
  element.classList.remove(this.styles.noSelect);
706
800
  }
707
801
  }, children: [_jsxs("colgroup", { children: [!this.props.hideLineNumbers && _jsx("col", { width: "50px" }), !splitView && !this.props.hideLineNumbers && _jsx("col", { width: "50px" }), this.props.renderGutter && _jsx("col", { width: "50px" }), _jsx("col", { width: "28px" }), _jsx("col", { width: "auto" }), splitView && (_jsxs(_Fragment, { children: [!this.props.hideLineNumbers && _jsx("col", { width: "50px" }), this.props.renderGutter && _jsx("col", { width: "50px" }), _jsx("col", { width: "28px" }), _jsx("col", { width: "auto" })] }))] }), _jsx("tbody", { children: nodes.diffNodes })] }));
708
- return (_jsxs("div", { style: Object.assign(Object.assign({}, scrollDivStyle), { position: 'relative' }), onScroll: this.onScroll, ref: this.state.scrollableContainerRef, children: [(!this.props.hideSummary || leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.stickyHeader, children: [!this.props.hideSummary && (_jsxs("div", { className: this.styles.summary, role: "banner", children: [_jsx("button", { type: "button", className: this.styles.allExpandButton, onClick: () => {
802
+ return (_jsxs("div", { style: Object.assign(Object.assign({}, scrollDivStyle), { position: 'relative' }), onScroll: this.onScroll, ref: this.state.scrollableContainerRef, children: [(!this.props.hideSummary || leftTitle || rightTitle) && (_jsxs("div", { ref: this.stickyHeaderRef, className: this.styles.stickyHeader, children: [!this.props.hideSummary && (_jsxs("div", { className: this.styles.summary, role: "banner", children: [_jsx("button", { type: "button", className: this.styles.allExpandButton, onClick: () => {
709
803
  this.setState({
710
804
  expandedBlocks: allExpanded
711
805
  ? []
712
806
  : nodes.blocks.map((b) => b.index),
713
- });
714
- }, children: allExpanded ? _jsx(Fold, {}) : _jsx(Expand, {}) }), " ", totalChanges, _jsx("div", { style: { display: "flex", gap: "1px" }, children: blocks }), this.props.summary ? _jsx("span", { children: this.props.summary }) : null] })), (leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.columnHeaders, children: [_jsx("div", { className: this.styles.titleBlock, children: leftTitle ? (_jsx("pre", { className: this.styles.contentText, children: leftTitle })) : null }), splitView && (_jsx("div", { className: this.styles.titleBlock, children: rightTitle ? (_jsx("pre", { className: this.styles.contentText, children: rightTitle })) : null }))] }))] })), this.state.isLoading && LoadingElement && _jsx(LoadingElement, {}), this.props.infiniteLoading ? (_jsx("div", { style: { minHeight: totalContentHeight, paddingTop: nodes.topPadding }, children: tableElement })) : (tableElement)] }));
807
+ }, () => this.recalculateOffsets());
808
+ }, children: allExpanded ? _jsx(Fold, {}) : _jsx(Expand, {}) }), " ", totalChanges, _jsx("div", { style: { display: "flex", gap: "1px" }, children: blocks }), this.props.summary ? _jsx("span", { children: this.props.summary }) : null] })), (leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.columnHeaders, children: [_jsx("div", { className: this.styles.titleBlock, children: leftTitle ? (_jsx("pre", { className: this.styles.contentText, children: leftTitle })) : null }), splitView && (_jsx("div", { className: this.styles.titleBlock, children: rightTitle ? (_jsx("pre", { className: this.styles.contentText, children: rightTitle })) : null }))] }))] })), this.state.isLoading && LoadingElement && _jsx(LoadingElement, {}), this.props.infiniteLoading ? (_jsx("div", { style: {
809
+ paddingTop: nodes.topPadding,
810
+ paddingBottom: nodes.bottomPadding,
811
+ }, children: tableElement })) : (tableElement), _jsx("span", { ref: this.charMeasureRef, style: {
812
+ position: 'absolute',
813
+ top: 0,
814
+ left: '-9999px',
815
+ visibility: 'hidden',
816
+ whiteSpace: 'pre',
817
+ fontFamily: 'monospace',
818
+ fontSize: 12,
819
+ }, "aria-hidden": "true", children: "M" }), this.props.infiniteLoading && this.props.showDebugInfo && (_jsxs("div", { style: {
820
+ position: 'fixed',
821
+ top: 10,
822
+ right: 10,
823
+ background: 'rgba(0,0,0,0.85)',
824
+ color: '#0f0',
825
+ padding: '10px',
826
+ fontFamily: 'monospace',
827
+ fontSize: '11px',
828
+ zIndex: 9999,
829
+ borderRadius: '4px',
830
+ maxWidth: '300px',
831
+ lineHeight: 1.4,
832
+ }, children: [_jsx("div", { style: { fontWeight: 'bold', marginBottom: '5px', color: '#fff' }, children: "Debug Info" }), _jsxs("div", { children: ["scrollTop: ", nodes.debug.scrollTop] }), _jsxs("div", { children: ["headerHeight: ", nodes.debug.headerHeight] }), _jsxs("div", { children: ["contentScrollTop: ", nodes.debug.contentScrollTop] }), _jsxs("div", { children: ["clientHeight: ", nodes.debug.clientHeight] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["visibleRowStart: ", nodes.debug.visibleRowStart] }), _jsxs("div", { children: ["visibleRowEnd: ", nodes.debug.visibleRowEnd] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["totalRows: ", nodes.debug.totalRows] }), _jsxs("div", { children: ["offsetsLength: ", nodes.debug.offsetsLength] }), _jsxs("div", { children: ["renderedCount: ", nodes.debug.renderedCount] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["topPadding: ", nodes.topPadding.toFixed(0)] }), _jsxs("div", { children: ["bottomPadding: ", nodes.bottomPadding.toFixed(0)] }), _jsxs("div", { children: ["totalContentHeight: ", nodes.totalContentHeight.toFixed(0)] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px', color: '#ff0' }, children: [_jsxs("div", { children: ["cumulativeOffsets: ", this.state.cumulativeOffsets ? 'SET' : 'NULL'] }), _jsxs("div", { children: ["columnWidth: ", (_b = (_a = this.state.contentColumnWidth) === null || _a === void 0 ? void 0 : _a.toFixed(0)) !== null && _b !== void 0 ? _b : 'N/A', "px"] }), _jsxs("div", { children: ["charWidth: ", (_d = (_c = this.state.charWidth) === null || _c === void 0 ? void 0 : _c.toFixed(2)) !== null && _d !== void 0 ? _d : 'N/A', "px"] }), _jsxs("div", { children: ["charsPerRow: ", this.state.contentColumnWidth && this.state.charWidth ? Math.floor(this.state.contentColumnWidth / this.state.charWidth) : 'N/A'] })] }), this.state.cumulativeOffsets && (_jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px', color: '#0ff', fontSize: '10px' }, children: [_jsxs("div", { children: ["offsets[", nodes.debug.visibleRowEnd, "]: ", (_f = (_e = this.state.cumulativeOffsets[nodes.debug.visibleRowEnd]) === null || _e === void 0 ? void 0 : _e.toFixed(0)) !== null && _f !== void 0 ? _f : 'N/A'] }), _jsxs("div", { children: ["offsets[", nodes.debug.totalRows - 1, "]: ", (_h = (_g = this.state.cumulativeOffsets[nodes.debug.totalRows - 1]) === null || _g === void 0 ? void 0 : _g.toFixed(0)) !== null && _h !== void 0 ? _h : 'N/A'] }), _jsxs("div", { children: ["offsets[", nodes.debug.totalRows, "]: ", (_k = (_j = this.state.cumulativeOffsets[nodes.debug.totalRows]) === null || _j === void 0 ? void 0 : _j.toFixed(0)) !== null && _k !== void 0 ? _k : 'N/A'] }), _jsxs("div", { style: { marginTop: '3px' }, children: ["viewportEnd: ", (nodes.debug.contentScrollTop + nodes.debug.clientHeight).toFixed(0)] }), _jsxs("div", { style: { marginTop: '3px', color: '#f0f' }, children: ["scrollHeight: ", (_m = (_l = this.state.scrollableContainerRef.current) === null || _l === void 0 ? void 0 : _l.scrollHeight) !== null && _m !== void 0 ? _m : 'N/A'] }), _jsxs("div", { children: ["maxScrollTop: ", ((_p = (_o = this.state.scrollableContainerRef.current) === null || _o === void 0 ? void 0 : _o.scrollHeight) !== null && _p !== void 0 ? _p : 0) - nodes.debug.clientHeight] })] }))] }))] }));
715
833
  };
716
834
  this.state = {
717
835
  expandedBlocks: [],
@@ -719,9 +837,117 @@ class DiffViewer extends React.Component {
719
837
  scrollableContainerRef: React.createRef(),
720
838
  computedDiffResult: {},
721
839
  isLoading: false,
722
- visibleStartRow: 0
840
+ visibleStartRow: 0,
841
+ contentColumnWidth: null,
842
+ charWidth: null,
843
+ cumulativeOffsets: null,
723
844
  };
724
845
  }
846
+ /**
847
+ * Gets the height of the sticky header, if present.
848
+ */
849
+ getStickyHeaderHeight() {
850
+ var _a;
851
+ return ((_a = this.stickyHeaderRef.current) === null || _a === void 0 ? void 0 : _a.offsetHeight) || 0;
852
+ }
853
+ /**
854
+ * Measures the width of a single character in the monospace font.
855
+ * Falls back to 7.2px if measurement fails.
856
+ */
857
+ measureCharWidth() {
858
+ const span = this.charMeasureRef.current;
859
+ if (!span)
860
+ return 7.2; // fallback
861
+ return span.getBoundingClientRect().width || 7.2;
862
+ }
863
+ /**
864
+ * Measures the available width for content in a content column.
865
+ * Falls back to estimating from container width if direct measurement fails.
866
+ */
867
+ measureContentColumnWidth() {
868
+ // Try direct measurement first
869
+ const cell = this.contentColumnRef.current;
870
+ if (cell && cell.clientWidth > 0) {
871
+ const style = window.getComputedStyle(cell);
872
+ const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
873
+ const width = cell.clientWidth - padding;
874
+ if (width > 0)
875
+ return width;
876
+ }
877
+ // Fallback: estimate from container width
878
+ // In split view: container has 2 content columns + gutters (50px each) + markers (28px each)
879
+ // In unified view: 1 content column + 2 gutters + 1 marker
880
+ const container = this.state.scrollableContainerRef.current;
881
+ if (!container || container.clientWidth <= 0)
882
+ return null;
883
+ const containerWidth = container.clientWidth;
884
+ const gutterWidth = this.props.hideLineNumbers ? 0 : 50;
885
+ const markerWidth = 28;
886
+ const gutterCount = this.props.splitView ? 2 : 2; // left gutter(s)
887
+ const markerCount = this.props.splitView ? 2 : 1;
888
+ const contentColumns = this.props.splitView ? 2 : 1;
889
+ const fixedWidth = gutterCount * gutterWidth + markerCount * markerWidth;
890
+ const availableWidth = containerWidth - fixedWidth;
891
+ return Math.max(100, availableWidth / contentColumns); // minimum 100px
892
+ }
893
+ /**
894
+ * Gets the text length from a value that may be a string or DiffInformation array.
895
+ */
896
+ getTextLength(value) {
897
+ if (!value)
898
+ return 0;
899
+ if (typeof value === 'string')
900
+ return value.length;
901
+ return value.reduce((sum, d) => sum + (typeof d.value === 'string' ? d.value.length : 0), 0);
902
+ }
903
+ /**
904
+ * Builds cumulative vertical offsets for each line based on character count and column width.
905
+ * This allows accurate scroll position calculations with variable row heights.
906
+ */
907
+ buildCumulativeOffsets(lineInformation, lineBlocks, blocks, expandedBlocks, showDiffOnly, charWidth, columnWidth, splitView) {
908
+ var _a, _b;
909
+ const offsets = [0];
910
+ const seenBlocks = new Set();
911
+ for (let i = 0; i < lineInformation.length; i++) {
912
+ const line = lineInformation[i];
913
+ if (showDiffOnly) {
914
+ const blockIndex = lineBlocks[i];
915
+ if (blockIndex !== undefined && !expandedBlocks.includes(blockIndex)) {
916
+ const isLastLine = blocks[blockIndex].endLine === i;
917
+ if (!seenBlocks.has(blockIndex) && isLastLine) {
918
+ seenBlocks.add(blockIndex);
919
+ offsets.push(offsets[offsets.length - 1] + DiffViewer.ESTIMATED_ROW_HEIGHT);
920
+ }
921
+ continue;
922
+ }
923
+ }
924
+ // Calculate visual rows for this line
925
+ const leftLen = ((_a = line.left) === null || _a === void 0 ? void 0 : _a.value) ? this.getTextLength(line.left.value) : 0;
926
+ const rightLen = ((_b = line.right) === null || _b === void 0 ? void 0 : _b.value) ? this.getTextLength(line.right.value) : 0;
927
+ const maxLen = splitView ? Math.max(leftLen, rightLen) : (leftLen || rightLen);
928
+ const charsPerRow = Math.floor(columnWidth / charWidth);
929
+ const visualRows = charsPerRow > 0 ? Math.max(1, Math.ceil(maxLen / charsPerRow)) : 1;
930
+ offsets.push(offsets[offsets.length - 1] + visualRows * DiffViewer.ESTIMATED_ROW_HEIGHT);
931
+ }
932
+ return offsets;
933
+ }
934
+ /**
935
+ * Binary search to find the line index at a given scroll offset.
936
+ */
937
+ findLineAtOffset(scrollTop, offsets) {
938
+ let low = 0;
939
+ let high = offsets.length - 2;
940
+ while (low < high) {
941
+ const mid = Math.floor((low + high + 1) / 2);
942
+ if (offsets[mid] <= scrollTop) {
943
+ low = mid;
944
+ }
945
+ else {
946
+ high = mid - 1;
947
+ }
948
+ }
949
+ return low;
950
+ }
725
951
  componentDidUpdate(prevProps) {
726
952
  if (prevProps.oldValue !== this.props.oldValue ||
727
953
  prevProps.newValue !== this.props.newValue ||
@@ -730,13 +956,27 @@ class DiffViewer extends React.Component {
730
956
  prevProps.linesOffset !== this.props.linesOffset) {
731
957
  // Clear word diff cache when diff changes
732
958
  this.wordDiffCache.clear();
733
- this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true, visibleStartRow: 0 })));
959
+ this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true, visibleStartRow: 0, cumulativeOffsets: null })));
734
960
  this.memoisedCompute();
735
961
  }
736
962
  }
737
963
  componentDidMount() {
738
964
  this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true })));
739
965
  this.memoisedCompute();
966
+ // Set up ResizeObserver for recalculating offsets on container resize
967
+ if (typeof ResizeObserver !== 'undefined' && this.props.infiniteLoading) {
968
+ this.resizeObserver = new ResizeObserver(() => {
969
+ requestAnimationFrame(() => this.recalculateOffsets());
970
+ });
971
+ const container = this.state.scrollableContainerRef.current;
972
+ if (container) {
973
+ this.resizeObserver.observe(container);
974
+ }
975
+ }
976
+ }
977
+ componentWillUnmount() {
978
+ var _a;
979
+ (_a = this.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
740
980
  }
741
981
  }
742
982
  DiffViewer.defaultProps = {
@@ -28,6 +28,7 @@ export interface ReactDiffViewerStyles {
28
28
  content?: string;
29
29
  column?: string;
30
30
  noSelect?: string;
31
+ noWrap?: string;
31
32
  splitView?: string;
32
33
  allExpandButton?: string;
33
34
  [key: string]: string | undefined;
@@ -72,6 +72,7 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
72
72
  const { css, cx } = createEmotion({ key: "react-diff", nonce });
73
73
  const content = css({
74
74
  width: "auto",
75
+ overflow: "hidden",
75
76
  label: "content",
76
77
  });
77
78
  const splitView = css({
@@ -113,6 +114,9 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
113
114
  },
114
115
  label: "diff-container",
115
116
  borderCollapse: "collapse",
117
+ "@media (max-width: 768px)": {
118
+ minWidth: "unset",
119
+ },
116
120
  });
117
121
  const lineContent = css({
118
122
  overflow: "hidden",
@@ -130,6 +134,16 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
130
134
  userSelect: "none",
131
135
  label: "unselectable",
132
136
  });
137
+ const noWrap = css({
138
+ label: "no-wrap",
139
+ pre: {
140
+ whiteSpace: "pre",
141
+ },
142
+ [`.${contentText}`]: {
143
+ whiteSpace: "pre",
144
+ lineBreak: "auto",
145
+ },
146
+ });
133
147
  const allExpandButton = css({
134
148
  background: "transparent",
135
149
  border: "none",
@@ -376,6 +390,7 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
376
390
  blockDeletion,
377
391
  wordRemoved,
378
392
  noSelect: unselectable,
393
+ noWrap,
379
394
  codeFoldGutter,
380
395
  codeFoldExpandButton,
381
396
  codeFoldContentContainer,
@@ -490,19 +490,29 @@ const computeLineInformation = (oldString, newString, disableWordDiff = false, l
490
490
  * @returns Promise<ComputedLineInformation> - Resolves with line-by-line diff data from the worker.
491
491
  */
492
492
  const computeLineInformationWorker = (oldString, newString, disableWordDiff = false, lineCompareMethod = DiffMethod.CHARS, linesOffset = 0, showLines = [], deferWordDiff = false) => {
493
+ const fallback = () => computeLineInformation(oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff);
493
494
  // Fall back to synchronous computation if Worker is not available (e.g., in Node.js/test environments)
494
495
  if (typeof Worker === 'undefined') {
495
- return Promise.resolve(computeLineInformation(oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff));
496
+ return Promise.resolve(fallback());
496
497
  }
497
- return new Promise((resolve, reject) => {
498
- const worker = new Worker(new URL('./computeWorker.ts', import.meta.url), { type: 'module' });
498
+ return new Promise((resolve) => {
499
+ let worker;
500
+ try {
501
+ worker = new Worker(new URL('./computeWorker.js', import.meta.url), { type: 'module' });
502
+ }
503
+ catch {
504
+ // Worker instantiation failed - fall back to synchronous computation
505
+ resolve(fallback());
506
+ return;
507
+ }
499
508
  worker.onmessage = (e) => {
500
509
  resolve(e.data);
501
510
  worker.terminate();
502
511
  };
503
- worker.onerror = (err) => {
504
- reject(err);
512
+ worker.onerror = () => {
513
+ // Worker error - fall back to synchronous computation
505
514
  worker.terminate();
515
+ resolve(fallback());
506
516
  };
507
517
  worker.postMessage({ oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff });
508
518
  });
@@ -151,6 +151,11 @@ class DiffViewer extends React.Component {
151
151
  styles;
152
152
  // Cache for on-demand word diff computation
153
153
  wordDiffCache = new Map();
154
+ // Refs for measuring content column width and character width
155
+ contentColumnRef = React.createRef();
156
+ charMeasureRef = React.createRef();
157
+ stickyHeaderRef = React.createRef();
158
+ resizeObserver = null;
154
159
  static defaultProps = {
155
160
  oldValue: "",
156
161
  newValue: "",
@@ -174,7 +179,10 @@ class DiffViewer extends React.Component {
174
179
  scrollableContainerRef: React.createRef(),
175
180
  computedDiffResult: {},
176
181
  isLoading: false,
177
- visibleStartRow: 0
182
+ visibleStartRow: 0,
183
+ contentColumnWidth: null,
184
+ charWidth: null,
185
+ cumulativeOffsets: null,
178
186
  };
179
187
  }
180
188
  /**
@@ -227,8 +235,130 @@ class DiffViewer extends React.Component {
227
235
  onBlockExpand = (id) => {
228
236
  const prevState = this.state.expandedBlocks.slice();
229
237
  prevState.push(id);
230
- this.setState({
231
- expandedBlocks: prevState,
238
+ this.setState({ expandedBlocks: prevState }, () => this.recalculateOffsets());
239
+ };
240
+ /**
241
+ * Gets the height of the sticky header, if present.
242
+ */
243
+ getStickyHeaderHeight() {
244
+ return this.stickyHeaderRef.current?.offsetHeight || 0;
245
+ }
246
+ /**
247
+ * Measures the width of a single character in the monospace font.
248
+ * Falls back to 7.2px if measurement fails.
249
+ */
250
+ measureCharWidth() {
251
+ const span = this.charMeasureRef.current;
252
+ if (!span)
253
+ return 7.2; // fallback
254
+ return span.getBoundingClientRect().width || 7.2;
255
+ }
256
+ /**
257
+ * Measures the available width for content in a content column.
258
+ * Falls back to estimating from container width if direct measurement fails.
259
+ */
260
+ measureContentColumnWidth() {
261
+ // Try direct measurement first
262
+ const cell = this.contentColumnRef.current;
263
+ if (cell && cell.clientWidth > 0) {
264
+ const style = window.getComputedStyle(cell);
265
+ const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
266
+ const width = cell.clientWidth - padding;
267
+ if (width > 0)
268
+ return width;
269
+ }
270
+ // Fallback: estimate from container width
271
+ // In split view: container has 2 content columns + gutters (50px each) + markers (28px each)
272
+ // In unified view: 1 content column + 2 gutters + 1 marker
273
+ const container = this.state.scrollableContainerRef.current;
274
+ if (!container || container.clientWidth <= 0)
275
+ return null;
276
+ const containerWidth = container.clientWidth;
277
+ const gutterWidth = this.props.hideLineNumbers ? 0 : 50;
278
+ const markerWidth = 28;
279
+ const gutterCount = this.props.splitView ? 2 : 2; // left gutter(s)
280
+ const markerCount = this.props.splitView ? 2 : 1;
281
+ const contentColumns = this.props.splitView ? 2 : 1;
282
+ const fixedWidth = gutterCount * gutterWidth + markerCount * markerWidth;
283
+ const availableWidth = containerWidth - fixedWidth;
284
+ return Math.max(100, availableWidth / contentColumns); // minimum 100px
285
+ }
286
+ /**
287
+ * Gets the text length from a value that may be a string or DiffInformation array.
288
+ */
289
+ getTextLength(value) {
290
+ if (!value)
291
+ return 0;
292
+ if (typeof value === 'string')
293
+ return value.length;
294
+ return value.reduce((sum, d) => sum + (typeof d.value === 'string' ? d.value.length : 0), 0);
295
+ }
296
+ /**
297
+ * Builds cumulative vertical offsets for each line based on character count and column width.
298
+ * This allows accurate scroll position calculations with variable row heights.
299
+ */
300
+ buildCumulativeOffsets(lineInformation, lineBlocks, blocks, expandedBlocks, showDiffOnly, charWidth, columnWidth, splitView) {
301
+ const offsets = [0];
302
+ const seenBlocks = new Set();
303
+ for (let i = 0; i < lineInformation.length; i++) {
304
+ const line = lineInformation[i];
305
+ if (showDiffOnly) {
306
+ const blockIndex = lineBlocks[i];
307
+ if (blockIndex !== undefined && !expandedBlocks.includes(blockIndex)) {
308
+ const isLastLine = blocks[blockIndex].endLine === i;
309
+ if (!seenBlocks.has(blockIndex) && isLastLine) {
310
+ seenBlocks.add(blockIndex);
311
+ offsets.push(offsets[offsets.length - 1] + DiffViewer.ESTIMATED_ROW_HEIGHT);
312
+ }
313
+ continue;
314
+ }
315
+ }
316
+ // Calculate visual rows for this line
317
+ const leftLen = line.left?.value ? this.getTextLength(line.left.value) : 0;
318
+ const rightLen = line.right?.value ? this.getTextLength(line.right.value) : 0;
319
+ const maxLen = splitView ? Math.max(leftLen, rightLen) : (leftLen || rightLen);
320
+ const charsPerRow = Math.floor(columnWidth / charWidth);
321
+ const visualRows = charsPerRow > 0 ? Math.max(1, Math.ceil(maxLen / charsPerRow)) : 1;
322
+ offsets.push(offsets[offsets.length - 1] + visualRows * DiffViewer.ESTIMATED_ROW_HEIGHT);
323
+ }
324
+ return offsets;
325
+ }
326
+ /**
327
+ * Binary search to find the line index at a given scroll offset.
328
+ */
329
+ findLineAtOffset(scrollTop, offsets) {
330
+ let low = 0;
331
+ let high = offsets.length - 2;
332
+ while (low < high) {
333
+ const mid = Math.floor((low + high + 1) / 2);
334
+ if (offsets[mid] <= scrollTop) {
335
+ low = mid;
336
+ }
337
+ else {
338
+ high = mid - 1;
339
+ }
340
+ }
341
+ return low;
342
+ }
343
+ /**
344
+ * Recalculates cumulative offsets based on current measurements.
345
+ * Called on resize and when blocks are expanded/collapsed.
346
+ */
347
+ recalculateOffsets = () => {
348
+ if (!this.props.infiniteLoading)
349
+ return;
350
+ const columnWidth = this.measureContentColumnWidth();
351
+ const charWidth = this.measureCharWidth();
352
+ if (!columnWidth)
353
+ return;
354
+ const cacheKey = this.getMemoisedKey();
355
+ const { lineInformation, lineBlocks, blocks } = this.state.computedDiffResult[cacheKey] ?? {};
356
+ if (!lineInformation)
357
+ return;
358
+ const offsets = this.buildCumulativeOffsets(lineInformation, lineBlocks, blocks, this.state.expandedBlocks, this.props.showDiffOnly, charWidth, columnWidth, this.props.splitView);
359
+ this.setState({ cumulativeOffsets: offsets, contentColumnWidth: columnWidth, charWidth }, () => {
360
+ // Force a scroll position update to recalculate visible rows with new offsets
361
+ this.onScroll();
232
362
  });
233
363
  };
234
364
  /**
@@ -394,7 +524,7 @@ class DiffViewer extends React.Component {
394
524
  [this.styles.diffRemoved]: removed,
395
525
  [this.styles.diffChanged]: changed,
396
526
  [this.styles.highlightedLine]: highlightLine,
397
- }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { className: cn(this.styles.content, {
527
+ }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { ref: prefix === LineNumberPrefix.LEFT && !this.state.cumulativeOffsets ? this.contentColumnRef : undefined, className: cn(this.styles.content, {
398
528
  [this.styles.emptyLine]: !content,
399
529
  [this.styles.diffAdded]: added,
400
530
  [this.styles.diffRemoved]: removed,
@@ -538,7 +668,13 @@ class DiffViewer extends React.Component {
538
668
  ...prev,
539
669
  computedDiffResult: this.state.computedDiffResult,
540
670
  isLoading: false,
541
- }));
671
+ }), () => {
672
+ // Trigger offset recalculation after diff is computed and rendered
673
+ // Use requestAnimationFrame to ensure DOM is ready for measurement
674
+ if (this.props.infiniteLoading) {
675
+ requestAnimationFrame(() => this.recalculateOffsets());
676
+ }
677
+ });
542
678
  };
543
679
  // Estimated row height based on lineHeight: 1.6em with 12px base font
544
680
  static ESTIMATED_ROW_HEIGHT = 19;
@@ -551,7 +687,13 @@ class DiffViewer extends React.Component {
551
687
  const container = this.state.scrollableContainerRef.current;
552
688
  if (!container || !this.props.infiniteLoading)
553
689
  return;
554
- const newStartRow = Math.floor(container.scrollTop / DiffViewer.ESTIMATED_ROW_HEIGHT);
690
+ // Account for sticky header height in scroll calculations
691
+ const headerHeight = this.getStickyHeaderHeight();
692
+ const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
693
+ const { cumulativeOffsets } = this.state;
694
+ const newStartRow = cumulativeOffsets
695
+ ? this.findLineAtOffset(contentScrollTop, cumulativeOffsets)
696
+ : Math.floor(contentScrollTop / DiffViewer.ESTIMATED_ROW_HEIGHT);
555
697
  // Only update state if the start row changed (avoid unnecessary re-renders)
556
698
  if (newStartRow !== this.state.visibleStartRow) {
557
699
  this.setState({ visibleStartRow: newStartRow });
@@ -562,7 +704,7 @@ class DiffViewer extends React.Component {
562
704
  */
563
705
  renderDiff = () => {
564
706
  const { splitView, infiniteLoading, showDiffOnly } = this.props;
565
- const { computedDiffResult, expandedBlocks, visibleStartRow, scrollableContainerRef } = this.state;
707
+ const { computedDiffResult, expandedBlocks, visibleStartRow, scrollableContainerRef, cumulativeOffsets } = this.state;
566
708
  const cacheKey = this.getMemoisedKey();
567
709
  const { lineInformation = [], lineBlocks = [], blocks = [] } = computedDiffResult[cacheKey] ?? {};
568
710
  // Calculate visible range for virtualization
@@ -571,9 +713,32 @@ class DiffViewer extends React.Component {
571
713
  const buffer = 5; // render extra rows above/below viewport
572
714
  if (infiniteLoading && scrollableContainerRef.current) {
573
715
  const container = scrollableContainerRef.current;
574
- const viewportRows = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
575
- visibleRowStart = Math.max(0, visibleStartRow - buffer);
576
- visibleRowEnd = visibleStartRow + viewportRows + buffer;
716
+ // Account for sticky header height in scroll calculations
717
+ const headerHeight = this.getStickyHeaderHeight();
718
+ const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
719
+ if (cumulativeOffsets) {
720
+ // Variable height mode: use binary search to find visible range
721
+ const totalHeight = cumulativeOffsets[cumulativeOffsets.length - 1] || 0;
722
+ const lastRowIndex = cumulativeOffsets.length - 2;
723
+ visibleRowStart = Math.max(0, this.findLineAtOffset(contentScrollTop, cumulativeOffsets) - buffer);
724
+ visibleRowEnd = this.findLineAtOffset(contentScrollTop + container.clientHeight, cumulativeOffsets) + buffer;
725
+ // IMPORTANT: The calculated offsets may overestimate row heights (based on char count),
726
+ // but actual CSS rendering might produce shorter rows. To prevent empty space,
727
+ // ensure we render at least enough rows to fill the viewport using ESTIMATED_ROW_HEIGHT
728
+ // as a conservative minimum.
729
+ const minRowsToFillViewport = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
730
+ visibleRowEnd = Math.max(visibleRowEnd, visibleRowStart + minRowsToFillViewport + buffer);
731
+ // Also ensure we render all rows when near the bottom
732
+ if (contentScrollTop + container.clientHeight >= totalHeight - buffer * DiffViewer.ESTIMATED_ROW_HEIGHT) {
733
+ visibleRowEnd = lastRowIndex + buffer;
734
+ }
735
+ }
736
+ else {
737
+ // Fixed height fallback
738
+ const viewportRows = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
739
+ visibleRowStart = Math.max(0, visibleStartRow - buffer);
740
+ visibleRowEnd = visibleStartRow + viewportRows + buffer;
741
+ }
577
742
  }
578
743
  // First pass: build a map of lineIndex -> renderedRowIndex
579
744
  // This accounts for code folding where some lines don't render or render as fold indicators
@@ -611,6 +776,7 @@ class DiffViewer extends React.Component {
611
776
  const diffNodes = [];
612
777
  let topPadding = 0;
613
778
  let firstVisibleFound = false;
779
+ let lastRenderedRowIndex = -1;
614
780
  seenBlocks.clear();
615
781
  for (let lineIndex = 0; lineIndex < lineInformation.length; lineIndex++) {
616
782
  const line = lineInformation[lineIndex];
@@ -628,9 +794,13 @@ class DiffViewer extends React.Component {
628
794
  }
629
795
  // Calculate top padding from the first visible row
630
796
  if (!firstVisibleFound) {
631
- topPadding = rowIndex * DiffViewer.ESTIMATED_ROW_HEIGHT;
797
+ topPadding = cumulativeOffsets
798
+ ? cumulativeOffsets[rowIndex] || 0
799
+ : rowIndex * DiffViewer.ESTIMATED_ROW_HEIGHT;
632
800
  firstVisibleFound = true;
633
801
  }
802
+ // Track the last rendered row for bottom padding calculation
803
+ lastRenderedRowIndex = rowIndex;
634
804
  // Render the line
635
805
  if (showDiffOnly) {
636
806
  const blockIndex = lineBlocks[lineIndex];
@@ -650,12 +820,37 @@ class DiffViewer extends React.Component {
650
820
  ? this.renderSplitView(line, lineIndex)
651
821
  : this.renderInlineView(line, lineIndex));
652
822
  }
823
+ // Calculate total content height
824
+ const totalContentHeight = cumulativeOffsets
825
+ ? cumulativeOffsets[cumulativeOffsets.length - 1] || 0
826
+ : totalRenderedRows * DiffViewer.ESTIMATED_ROW_HEIGHT;
827
+ // Calculate bottom padding: space after the last rendered row
828
+ const bottomPadding = cumulativeOffsets && lastRenderedRowIndex >= 0
829
+ ? totalContentHeight - (cumulativeOffsets[lastRenderedRowIndex + 1] || totalContentHeight)
830
+ : 0;
653
831
  return {
654
832
  diffNodes,
655
833
  blocks,
656
834
  lineInformation,
657
835
  totalRenderedRows,
658
836
  topPadding,
837
+ bottomPadding,
838
+ totalContentHeight,
839
+ renderedCount: diffNodes.length,
840
+ // Debug info
841
+ debug: {
842
+ visibleRowStart,
843
+ visibleRowEnd,
844
+ totalRows: totalRenderedRows,
845
+ offsetsLength: cumulativeOffsets?.length ?? 0,
846
+ renderedCount: diffNodes.length,
847
+ scrollTop: scrollableContainerRef.current?.scrollTop ?? 0,
848
+ headerHeight: this.getStickyHeaderHeight(),
849
+ contentScrollTop: scrollableContainerRef.current
850
+ ? Math.max(0, scrollableContainerRef.current.scrollTop - this.getStickyHeaderHeight())
851
+ : 0,
852
+ clientHeight: scrollableContainerRef.current?.clientHeight ?? 0,
853
+ }
659
854
  };
660
855
  };
661
856
  componentDidUpdate(prevProps) {
@@ -669,7 +864,8 @@ class DiffViewer extends React.Component {
669
864
  this.setState((prev) => ({
670
865
  ...prev,
671
866
  isLoading: true,
672
- visibleStartRow: 0
867
+ visibleStartRow: 0,
868
+ cumulativeOffsets: null,
673
869
  }));
674
870
  this.memoisedCompute();
675
871
  }
@@ -680,6 +876,19 @@ class DiffViewer extends React.Component {
680
876
  isLoading: true
681
877
  }));
682
878
  this.memoisedCompute();
879
+ // Set up ResizeObserver for recalculating offsets on container resize
880
+ if (typeof ResizeObserver !== 'undefined' && this.props.infiniteLoading) {
881
+ this.resizeObserver = new ResizeObserver(() => {
882
+ requestAnimationFrame(() => this.recalculateOffsets());
883
+ });
884
+ const container = this.state.scrollableContainerRef.current;
885
+ if (container) {
886
+ this.resizeObserver.observe(container);
887
+ }
888
+ }
889
+ }
890
+ componentWillUnmount() {
891
+ this.resizeObserver?.disconnect();
683
892
  }
684
893
  render = () => {
685
894
  const { oldValue, newValue, useDarkTheme, leftTitle, rightTitle, splitView, compareMethod, hideLineNumbers, nonce, } = this.props;
@@ -735,9 +944,12 @@ class DiffViewer extends React.Component {
735
944
  overflowX: 'hidden',
736
945
  height: this.props.infiniteLoading.containerHeight
737
946
  } : {};
738
- const totalContentHeight = nodes.totalRenderedRows * DiffViewer.ESTIMATED_ROW_HEIGHT;
947
+ // Only apply noWrap when infiniteLoading is enabled but we don't have cumulative offsets yet
948
+ // Once offsets are calculated, we enable pre-wrap for proper text wrapping
949
+ const shouldNoWrap = !!this.props.infiniteLoading && !this.state.cumulativeOffsets;
739
950
  const tableElement = (_jsxs("table", { className: cn(this.styles.diffContainer, {
740
951
  [this.styles.splitView]: splitView,
952
+ [this.styles.noWrap]: shouldNoWrap,
741
953
  }), onMouseUp: () => {
742
954
  const elements = document.getElementsByClassName("right");
743
955
  for (let i = 0; i < elements.length; i++) {
@@ -750,13 +962,37 @@ class DiffViewer extends React.Component {
750
962
  element.classList.remove(this.styles.noSelect);
751
963
  }
752
964
  }, children: [_jsxs("colgroup", { children: [!this.props.hideLineNumbers && _jsx("col", { width: "50px" }), !splitView && !this.props.hideLineNumbers && _jsx("col", { width: "50px" }), this.props.renderGutter && _jsx("col", { width: "50px" }), _jsx("col", { width: "28px" }), _jsx("col", { width: "auto" }), splitView && (_jsxs(_Fragment, { children: [!this.props.hideLineNumbers && _jsx("col", { width: "50px" }), this.props.renderGutter && _jsx("col", { width: "50px" }), _jsx("col", { width: "28px" }), _jsx("col", { width: "auto" })] }))] }), _jsx("tbody", { children: nodes.diffNodes })] }));
753
- return (_jsxs("div", { style: { ...scrollDivStyle, position: 'relative' }, onScroll: this.onScroll, ref: this.state.scrollableContainerRef, children: [(!this.props.hideSummary || leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.stickyHeader, children: [!this.props.hideSummary && (_jsxs("div", { className: this.styles.summary, role: "banner", children: [_jsx("button", { type: "button", className: this.styles.allExpandButton, onClick: () => {
965
+ return (_jsxs("div", { style: { ...scrollDivStyle, position: 'relative' }, onScroll: this.onScroll, ref: this.state.scrollableContainerRef, children: [(!this.props.hideSummary || leftTitle || rightTitle) && (_jsxs("div", { ref: this.stickyHeaderRef, className: this.styles.stickyHeader, children: [!this.props.hideSummary && (_jsxs("div", { className: this.styles.summary, role: "banner", children: [_jsx("button", { type: "button", className: this.styles.allExpandButton, onClick: () => {
754
966
  this.setState({
755
967
  expandedBlocks: allExpanded
756
968
  ? []
757
969
  : nodes.blocks.map((b) => b.index),
758
- });
759
- }, children: allExpanded ? _jsx(Fold, {}) : _jsx(Expand, {}) }), " ", totalChanges, _jsx("div", { style: { display: "flex", gap: "1px" }, children: blocks }), this.props.summary ? _jsx("span", { children: this.props.summary }) : null] })), (leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.columnHeaders, children: [_jsx("div", { className: this.styles.titleBlock, children: leftTitle ? (_jsx("pre", { className: this.styles.contentText, children: leftTitle })) : null }), splitView && (_jsx("div", { className: this.styles.titleBlock, children: rightTitle ? (_jsx("pre", { className: this.styles.contentText, children: rightTitle })) : null }))] }))] })), this.state.isLoading && LoadingElement && _jsx(LoadingElement, {}), this.props.infiniteLoading ? (_jsx("div", { style: { minHeight: totalContentHeight, paddingTop: nodes.topPadding }, children: tableElement })) : (tableElement)] }));
970
+ }, () => this.recalculateOffsets());
971
+ }, children: allExpanded ? _jsx(Fold, {}) : _jsx(Expand, {}) }), " ", totalChanges, _jsx("div", { style: { display: "flex", gap: "1px" }, children: blocks }), this.props.summary ? _jsx("span", { children: this.props.summary }) : null] })), (leftTitle || rightTitle) && (_jsxs("div", { className: this.styles.columnHeaders, children: [_jsx("div", { className: this.styles.titleBlock, children: leftTitle ? (_jsx("pre", { className: this.styles.contentText, children: leftTitle })) : null }), splitView && (_jsx("div", { className: this.styles.titleBlock, children: rightTitle ? (_jsx("pre", { className: this.styles.contentText, children: rightTitle })) : null }))] }))] })), this.state.isLoading && LoadingElement && _jsx(LoadingElement, {}), this.props.infiniteLoading ? (_jsx("div", { style: {
972
+ paddingTop: nodes.topPadding,
973
+ paddingBottom: nodes.bottomPadding,
974
+ }, children: tableElement })) : (tableElement), _jsx("span", { ref: this.charMeasureRef, style: {
975
+ position: 'absolute',
976
+ top: 0,
977
+ left: '-9999px',
978
+ visibility: 'hidden',
979
+ whiteSpace: 'pre',
980
+ fontFamily: 'monospace',
981
+ fontSize: 12,
982
+ }, "aria-hidden": "true", children: "M" }), this.props.infiniteLoading && this.props.showDebugInfo && (_jsxs("div", { style: {
983
+ position: 'fixed',
984
+ top: 10,
985
+ right: 10,
986
+ background: 'rgba(0,0,0,0.85)',
987
+ color: '#0f0',
988
+ padding: '10px',
989
+ fontFamily: 'monospace',
990
+ fontSize: '11px',
991
+ zIndex: 9999,
992
+ borderRadius: '4px',
993
+ maxWidth: '300px',
994
+ lineHeight: 1.4,
995
+ }, children: [_jsx("div", { style: { fontWeight: 'bold', marginBottom: '5px', color: '#fff' }, children: "Debug Info" }), _jsxs("div", { children: ["scrollTop: ", nodes.debug.scrollTop] }), _jsxs("div", { children: ["headerHeight: ", nodes.debug.headerHeight] }), _jsxs("div", { children: ["contentScrollTop: ", nodes.debug.contentScrollTop] }), _jsxs("div", { children: ["clientHeight: ", nodes.debug.clientHeight] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["visibleRowStart: ", nodes.debug.visibleRowStart] }), _jsxs("div", { children: ["visibleRowEnd: ", nodes.debug.visibleRowEnd] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["totalRows: ", nodes.debug.totalRows] }), _jsxs("div", { children: ["offsetsLength: ", nodes.debug.offsetsLength] }), _jsxs("div", { children: ["renderedCount: ", nodes.debug.renderedCount] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px' }, children: [_jsxs("div", { children: ["topPadding: ", nodes.topPadding.toFixed(0)] }), _jsxs("div", { children: ["bottomPadding: ", nodes.bottomPadding.toFixed(0)] }), _jsxs("div", { children: ["totalContentHeight: ", nodes.totalContentHeight.toFixed(0)] })] }), _jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px', color: '#ff0' }, children: [_jsxs("div", { children: ["cumulativeOffsets: ", this.state.cumulativeOffsets ? 'SET' : 'NULL'] }), _jsxs("div", { children: ["columnWidth: ", this.state.contentColumnWidth?.toFixed(0) ?? 'N/A', "px"] }), _jsxs("div", { children: ["charWidth: ", this.state.charWidth?.toFixed(2) ?? 'N/A', "px"] }), _jsxs("div", { children: ["charsPerRow: ", this.state.contentColumnWidth && this.state.charWidth ? Math.floor(this.state.contentColumnWidth / this.state.charWidth) : 'N/A'] })] }), this.state.cumulativeOffsets && (_jsxs("div", { style: { marginTop: '5px', borderTop: '1px solid #444', paddingTop: '5px', color: '#0ff', fontSize: '10px' }, children: [_jsxs("div", { children: ["offsets[", nodes.debug.visibleRowEnd, "]: ", this.state.cumulativeOffsets[nodes.debug.visibleRowEnd]?.toFixed(0) ?? 'N/A'] }), _jsxs("div", { children: ["offsets[", nodes.debug.totalRows - 1, "]: ", this.state.cumulativeOffsets[nodes.debug.totalRows - 1]?.toFixed(0) ?? 'N/A'] }), _jsxs("div", { children: ["offsets[", nodes.debug.totalRows, "]: ", this.state.cumulativeOffsets[nodes.debug.totalRows]?.toFixed(0) ?? 'N/A'] }), _jsxs("div", { style: { marginTop: '3px' }, children: ["viewportEnd: ", (nodes.debug.contentScrollTop + nodes.debug.clientHeight).toFixed(0)] }), _jsxs("div", { style: { marginTop: '3px', color: '#f0f' }, children: ["scrollHeight: ", this.state.scrollableContainerRef.current?.scrollHeight ?? 'N/A'] }), _jsxs("div", { children: ["maxScrollTop: ", (this.state.scrollableContainerRef.current?.scrollHeight ?? 0) - nodes.debug.clientHeight] })] }))] }))] }));
760
996
  };
761
997
  }
762
998
  export default DiffViewer;
@@ -67,6 +67,7 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
67
67
  const { css, cx } = createEmotion({ key: "react-diff", nonce });
68
68
  const content = css({
69
69
  width: "auto",
70
+ overflow: "hidden",
70
71
  label: "content",
71
72
  });
72
73
  const splitView = css({
@@ -108,6 +109,9 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
108
109
  },
109
110
  label: "diff-container",
110
111
  borderCollapse: "collapse",
112
+ "@media (max-width: 768px)": {
113
+ minWidth: "unset",
114
+ },
111
115
  });
112
116
  const lineContent = css({
113
117
  overflow: "hidden",
@@ -125,6 +129,16 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
125
129
  userSelect: "none",
126
130
  label: "unselectable",
127
131
  });
132
+ const noWrap = css({
133
+ label: "no-wrap",
134
+ pre: {
135
+ whiteSpace: "pre",
136
+ },
137
+ [`.${contentText}`]: {
138
+ whiteSpace: "pre",
139
+ lineBreak: "auto",
140
+ },
141
+ });
128
142
  const allExpandButton = css({
129
143
  background: "transparent",
130
144
  border: "none",
@@ -371,6 +385,7 @@ export default (styleOverride, useDarkTheme = false, nonce = "") => {
371
385
  blockDeletion,
372
386
  wordRemoved,
373
387
  noSelect: unselectable,
388
+ noWrap,
374
389
  codeFoldGutter,
375
390
  codeFoldExpandButton,
376
391
  codeFoldContentContainer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-diff-viewer-continued",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "private": false,
5
5
  "description": "Continuation of a simple and beautiful text diff viewer component made with diff and React",
6
6
  "keywords": [
@@ -65,7 +65,7 @@
65
65
  "node": ">= 16"
66
66
  },
67
67
  "scripts": {
68
- "build": "tsc --project tsconfig.json && tsc --project tsconfig.esm.json",
68
+ "build": "tsc --project tsconfig.json && tsc --project tsconfig.esm.json && sed -i 's/computeWorker\\.ts/computeWorker.js/g' lib/cjs/src/compute-lines.js lib/esm/src/compute-lines.js",
69
69
  "build:examples": "vite build examples",
70
70
  "publish:examples": "NODE_ENV=production pnpm run build:examples && gh-pages -d examples/dist -r $GITHUB_REPO_URL",
71
71
  "publish:examples:local": "NODE_ENV=production pnpm run build:examples && gh-pages -d examples/dist",