react-diff-viewer-continued
Version:
Continuation of a simple and beautiful text diff viewer component made with diff and React
833 lines • 58.5 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import cn from "classnames";
import * as React from "react";
import memoize from "memoize-one";
import { computeHiddenBlocks } from "./compute-hidden-blocks.js";
import { DiffMethod, DiffType, computeLineInformationWorker, computeDiff, } from "./compute-lines.js";
import { Expand } from "./expand.js";
import computeStyles from "./styles.js";
import { Fold } from "./fold.js";
/**
* Applies diff styling (ins/del tags) to pre-highlighted HTML by walking through
* the HTML and wrapping text portions based on character positions in the diff.
*/
function applyDiffToHighlightedHtml(html, diffArray, styles) {
const ranges = [];
let pos = 0;
for (const diff of diffArray) {
const value = typeof diff.value === "string" ? diff.value : "";
if (value.length > 0) {
ranges.push({ start: pos, end: pos + value.length, type: diff.type });
pos += value.length;
}
}
const segments = [];
let i = 0;
while (i < html.length) {
if (html[i] === "<") {
const tagEnd = html.indexOf(">", i);
if (tagEnd === -1) {
// Malformed HTML, treat rest as text
segments.push({ type: "text", content: html.slice(i) });
break;
}
segments.push({ type: "tag", content: html.slice(i, tagEnd + 1) });
i = tagEnd + 1;
}
else {
// Find the next tag or end of string
let textEnd = html.indexOf("<", i);
if (textEnd === -1)
textEnd = html.length;
segments.push({ type: "text", content: html.slice(i, textEnd) });
i = textEnd;
}
}
// Helper to decode HTML entities for character counting
function decodeEntities(text) {
return text
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, "\u00A0");
}
// Helper to get the wrapper tag for a diff type
function getWrapper(type) {
if (type === DiffType.ADDED) {
return {
open: `<ins class="${styles.wordDiff} ${styles.wordAdded}">`,
close: "</ins>",
};
}
if (type === DiffType.REMOVED) {
return {
open: `<del class="${styles.wordDiff} ${styles.wordRemoved}">`,
close: "</del>",
};
}
return {
open: `<span class="${styles.wordDiff}">`,
close: "</span>",
};
}
// Process segments, tracking text position
let textPos = 0;
let result = "";
for (const segment of segments) {
if (segment.type === "tag") {
result += segment.content;
}
else {
// Text segment - we need to split it according to diff ranges
const text = segment.content;
const decodedText = decodeEntities(text);
// Walk through the text, character by character (in decoded form)
// but output the original encoded form
let localDecodedPos = 0;
let localEncodedPos = 0;
while (localDecodedPos < decodedText.length) {
const globalPos = textPos + localDecodedPos;
// Find the range that covers this position
const range = ranges.find((r) => globalPos >= r.start && globalPos < r.end);
if (!range) {
// No range covers this position (shouldn't happen, but be safe)
// Just output the character
const char = text[localEncodedPos];
result += char;
localEncodedPos++;
localDecodedPos++;
continue;
}
// How many decoded characters until the end of this range?
const charsUntilRangeEnd = range.end - globalPos;
// How many decoded characters until the end of this text segment?
const charsUntilTextEnd = decodedText.length - localDecodedPos;
// Take the minimum
const charsToTake = Math.min(charsUntilRangeEnd, charsUntilTextEnd);
// Now we need to find the corresponding encoded substring
// Walk through encoded text, counting decoded characters
let encodedChunkEnd = localEncodedPos;
let decodedCount = 0;
while (decodedCount < charsToTake && encodedChunkEnd < text.length) {
if (text[encodedChunkEnd] === "&") {
// Find entity end
const entityEnd = text.indexOf(";", encodedChunkEnd);
if (entityEnd !== -1 && entityEnd - encodedChunkEnd < 10) {
encodedChunkEnd = entityEnd + 1;
}
else {
encodedChunkEnd++;
}
}
else {
encodedChunkEnd++;
}
decodedCount++;
}
const chunk = text.slice(localEncodedPos, encodedChunkEnd);
const wrapper = getWrapper(range.type);
if (wrapper) {
result += wrapper.open + chunk + wrapper.close;
}
else {
result += chunk;
}
localEncodedPos = encodedChunkEnd;
localDecodedPos += charsToTake;
}
textPos += decodedText.length;
}
}
return result;
}
export var LineNumberPrefix;
(function (LineNumberPrefix) {
LineNumberPrefix["LEFT"] = "L";
LineNumberPrefix["RIGHT"] = "R";
})(LineNumberPrefix || (LineNumberPrefix = {}));
class DiffViewer extends React.Component {
constructor(props) {
super(props);
// Cache for on-demand word diff computation
this.wordDiffCache = new Map();
// Refs for measuring content column width and character width
this.contentColumnRef = React.createRef();
this.charMeasureRef = React.createRef();
this.stickyHeaderRef = React.createRef();
this.resizeObserver = null;
/**
* Computes word diff on-demand for a line, with caching.
* This is used when word diff was deferred during initial computation.
*/
this.getWordDiffValues = (left, right, lineIndex) => {
// Handle empty left/right
if (!left || !right) {
return { leftValue: left === null || left === void 0 ? void 0 : left.value, rightValue: right === null || right === void 0 ? void 0 : right.value };
}
// If no raw values, word diff was already computed or disabled
// Use explicit undefined check since empty string is a valid raw value
if (left.rawValue === undefined || right.rawValue === undefined) {
return { leftValue: left.value, rightValue: right.value };
}
// Check cache
const cacheKey = `${lineIndex}-${left.rawValue}-${right.rawValue}`;
let cached = this.wordDiffCache.get(cacheKey);
if (!cached) {
// Compute word diff on-demand
// Use CHARS method for on-demand computation since rawValue is always a string
// (JSON/YAML methods only work with objects, not the string lines we have here)
const compareMethod = (this.props.compareMethod === DiffMethod.JSON || this.props.compareMethod === DiffMethod.YAML)
? DiffMethod.CHARS
: this.props.compareMethod;
const computed = computeDiff(left.rawValue, right.rawValue, compareMethod);
cached = { left: computed.left, right: computed.right };
this.wordDiffCache.set(cacheKey, cached);
}
return { leftValue: cached.left, rightValue: cached.right };
};
/**
* Resets code block expand to the initial stage. Will be exposed to the parent component via
* refs.
*/
this.resetCodeBlocks = () => {
if (this.state.expandedBlocks.length > 0) {
this.setState({
expandedBlocks: [],
});
return true;
}
return false;
};
/**
* Pushes the target expanded code block to the state. During the re-render,
* this value is used to expand/fold unmodified code.
*/
this.onBlockExpand = (id) => {
const prevState = this.state.expandedBlocks.slice();
prevState.push(id);
this.setState({ expandedBlocks: prevState }, () => this.recalculateOffsets());
};
/**
* Recalculates cumulative offsets based on current measurements.
* Called on resize and when blocks are expanded/collapsed.
*/
this.recalculateOffsets = () => {
var _a;
if (!this.props.infiniteLoading)
return;
const columnWidth = this.measureContentColumnWidth();
const charWidth = this.measureCharWidth();
if (!columnWidth)
return;
const cacheKey = this.getMemoisedKey();
const { lineInformation, lineBlocks, blocks } = (_a = this.state.computedDiffResult[cacheKey]) !== null && _a !== void 0 ? _a : {};
if (!lineInformation)
return;
const offsets = this.buildCumulativeOffsets(lineInformation, lineBlocks, blocks, this.state.expandedBlocks, this.props.showDiffOnly, charWidth, columnWidth, this.props.splitView);
this.setState({ cumulativeOffsets: offsets, contentColumnWidth: columnWidth, charWidth }, () => {
// Force a scroll position update to recalculate visible rows with new offsets
this.onScroll();
});
};
/**
* Computes final styles for the diff viewer. It combines the default styles with the user
* supplied overrides. The computed styles are cached with performance in mind.
*
* @param styles User supplied style overrides.
*/
this.computeStyles = memoize(computeStyles);
/**
* Returns a function with clicked line number in the closure. Returns an no-op function when no
* onLineNumberClick handler is supplied.
*
* @param id Line id of a line.
*/
this.onLineNumberClickProxy = (id) => {
if (this.props.onLineNumberClick) {
return (e) => this.props.onLineNumberClick(id, e);
}
return () => { };
};
/**
* Checks if the current compare method should show word-level highlighting.
* Character, word-level, JSON, and YAML diffs benefit from highlighting individual changes.
* JSON/YAML use CHARS internally for word-level diff, so they should be highlighted.
*/
this.shouldHighlightWordDiff = () => {
const { compareMethod } = this.props;
return (compareMethod === DiffMethod.CHARS ||
compareMethod === DiffMethod.WORDS ||
compareMethod === DiffMethod.WORDS_WITH_SPACE ||
compareMethod === DiffMethod.JSON ||
compareMethod === DiffMethod.YAML);
};
/**
* Maps over the word diff and constructs the required React elements to show word diff.
*
* @param diffArray Word diff information derived from line information.
* @param renderer Optional renderer to format diff words. Useful for syntax highlighting.
*/
this.renderWordDiff = (diffArray, renderer) => {
var _a, _b;
const showHighlight = this.shouldHighlightWordDiff();
// Reconstruct the full line from diff chunks
const fullLine = diffArray
.map((d) => (typeof d.value === "string" ? d.value : ""))
.join("");
// For very long lines (>500 chars), skip fancy processing - just render plain text
// without word-level highlighting to avoid performance issues
const MAX_LINE_LENGTH = 500;
if (fullLine.length > MAX_LINE_LENGTH) {
return [_jsx("span", { children: fullLine }, "long-line")];
}
// If we have a renderer, try to highlight the full line first,
// then apply diff styling to preserve proper tokenization.
if (renderer) {
// Get the syntax-highlighted content
const highlighted = renderer(fullLine);
// Check if the renderer uses dangerouslySetInnerHTML (common with Prism, highlight.js, etc.)
const htmlContent = (_b = (_a = highlighted === null || highlighted === void 0 ? void 0 : highlighted.props) === null || _a === void 0 ? void 0 : _a.dangerouslySetInnerHTML) === null || _b === void 0 ? void 0 : _b.__html;
if (typeof htmlContent === "string") {
// Apply diff styling to the highlighted HTML
const styledHtml = applyDiffToHighlightedHtml(htmlContent, diffArray, {
wordDiff: this.styles.wordDiff,
wordAdded: showHighlight ? this.styles.wordAdded : "",
wordRemoved: showHighlight ? this.styles.wordRemoved : "",
});
// Clone the element with the modified HTML
return [
React.cloneElement(highlighted, {
key: "highlighted-diff",
dangerouslySetInnerHTML: { __html: styledHtml },
}),
];
}
// Renderer doesn't use dangerouslySetInnerHTML - fall through to per-chunk rendering
}
// Fallback: render each chunk separately (used for JSON/YAML or non-HTML renderers)
return diffArray.map((wordDiff, i) => {
let content;
if (typeof wordDiff.value === "string") {
content = wordDiff.value;
}
else {
// If wordDiff.value is DiffInformation[], we don't handle it. See c0c99f5712.
content = undefined;
}
return wordDiff.type === DiffType.ADDED ? (_jsx("ins", { className: cn(this.styles.wordDiff, {
[this.styles.wordAdded]: showHighlight,
}), children: content }, i)) : wordDiff.type === DiffType.REMOVED ? (_jsx("del", { className: cn(this.styles.wordDiff, {
[this.styles.wordRemoved]: showHighlight,
}), children: content }, i)) : (_jsx("span", { className: cn(this.styles.wordDiff), children: content }, i));
});
};
/**
* Maps over the line diff and constructs the required react elements to show line diff. It calls
* renderWordDiff when encountering word diff. This takes care of both inline and split view line
* renders.
*
* @param lineNumber Line number of the current line.
* @param type Type of diff of the current line.
* @param prefix Unique id to prefix with the line numbers.
* @param value Content of the line. It can be a string or a word diff array.
* @param additionalLineNumber Additional line number to be shown. Useful for rendering inline
* diff view. Right line number will be passed as additionalLineNumber.
* @param additionalPrefix Similar to prefix but for additional line number.
*/
this.renderLine = (lineNumber, type, prefix, value, additionalLineNumber, additionalPrefix) => {
const lineNumberTemplate = `${prefix}-${lineNumber}`;
const additionalLineNumberTemplate = `${additionalPrefix}-${additionalLineNumber}`;
const highlightLine = this.props.highlightLines.includes(lineNumberTemplate) ||
this.props.highlightLines.includes(additionalLineNumberTemplate);
const added = type === DiffType.ADDED;
const removed = type === DiffType.REMOVED;
const changed = type === DiffType.CHANGED;
let content;
const hasWordDiff = Array.isArray(value);
if (hasWordDiff) {
content = this.renderWordDiff(value, this.props.renderContent);
}
else if (this.props.renderContent) {
content = this.props.renderContent(value);
}
else {
content = value;
}
let ElementType = "div";
if (added && !hasWordDiff) {
ElementType = "ins";
}
else if (removed && !hasWordDiff) {
ElementType = "del";
}
return (_jsxs(_Fragment, { children: [!this.props.hideLineNumbers && (_jsx("td", { onClick: lineNumber && this.onLineNumberClickProxy(lineNumberTemplate), className: cn(this.styles.gutter, {
[this.styles.emptyGutter]: !lineNumber,
[this.styles.diffAdded]: added,
[this.styles.diffRemoved]: removed,
[this.styles.diffChanged]: changed,
[this.styles.highlightedGutter]: highlightLine,
}), children: _jsx("pre", { className: this.styles.lineNumber, children: lineNumber }) })), !this.props.splitView && !this.props.hideLineNumbers && (_jsx("td", { onClick: additionalLineNumber &&
this.onLineNumberClickProxy(additionalLineNumberTemplate), className: cn(this.styles.gutter, {
[this.styles.emptyGutter]: !additionalLineNumber,
[this.styles.diffAdded]: added,
[this.styles.diffRemoved]: removed,
[this.styles.diffChanged]: changed,
[this.styles.highlightedGutter]: highlightLine,
}), children: _jsx("pre", { className: this.styles.lineNumber, children: additionalLineNumber }) })), this.props.renderGutter
? this.props.renderGutter({
lineNumber,
type,
prefix,
value,
additionalLineNumber,
additionalPrefix,
styles: this.styles,
})
: null, _jsx("td", { className: cn(this.styles.marker, {
[this.styles.emptyLine]: !content,
[this.styles.diffAdded]: added,
[this.styles.diffRemoved]: removed,
[this.styles.diffChanged]: changed,
[this.styles.highlightedLine]: highlightLine,
}), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { ref: prefix === LineNumberPrefix.LEFT && !this.state.cumulativeOffsets ? this.contentColumnRef : undefined, className: cn(this.styles.content, {
[this.styles.emptyLine]: !content,
[this.styles.diffAdded]: added,
[this.styles.diffRemoved]: removed,
[this.styles.diffChanged]: changed,
[this.styles.highlightedLine]: highlightLine,
left: prefix === LineNumberPrefix.LEFT,
right: prefix === LineNumberPrefix.RIGHT,
}), onMouseDown: () => {
const elements = document.getElementsByClassName(prefix === LineNumberPrefix.LEFT ? "right" : "left");
for (let i = 0; i < elements.length; i++) {
const element = elements.item(i);
element.classList.add(this.styles.noSelect);
}
}, title: added && !hasWordDiff
? "Added line"
: removed && !hasWordDiff
? "Removed line"
: undefined, children: _jsx(ElementType, { className: this.styles.contentText, children: content }) })] }));
};
/**
* Generates lines for split view.
*
* @param obj Line diff information.
* @param obj.left Life diff information for the left pane of the split view.
* @param obj.right Life diff information for the right pane of the split view.
* @param index React key for the lines.
*/
this.renderSplitView = ({ left, right }, index) => {
// Compute word diff on-demand if deferred
const { leftValue, rightValue } = this.getWordDiffValues(left, right, index);
return (_jsxs("tr", { className: this.styles.line, children: [this.renderLine(left.lineNumber, left.type, LineNumberPrefix.LEFT, leftValue), this.renderLine(right.lineNumber, right.type, LineNumberPrefix.RIGHT, rightValue)] }, index));
};
/**
* Generates lines for inline view.
*
* @param obj Line diff information.
* @param obj.left Life diff information for the added section of the inline view.
* @param obj.right Life diff information for the removed section of the inline view.
* @param index React key for the lines.
*/
this.renderInlineView = ({ left, right }, index) => {
// Compute word diff on-demand if deferred
const { leftValue, rightValue } = this.getWordDiffValues(left, right, index);
let content;
if (left.type === DiffType.REMOVED && right.type === DiffType.ADDED) {
return (_jsxs(React.Fragment, { children: [_jsx("tr", { className: this.styles.line, children: this.renderLine(left.lineNumber, left.type, LineNumberPrefix.LEFT, leftValue, null) }), _jsx("tr", { className: this.styles.line, children: this.renderLine(null, right.type, LineNumberPrefix.RIGHT, rightValue, right.lineNumber, LineNumberPrefix.RIGHT) })] }, index));
}
if (left.type === DiffType.REMOVED) {
content = this.renderLine(left.lineNumber, left.type, LineNumberPrefix.LEFT, leftValue, null);
}
if (left.type === DiffType.DEFAULT) {
content = this.renderLine(left.lineNumber, left.type, LineNumberPrefix.LEFT, leftValue, right.lineNumber, LineNumberPrefix.RIGHT);
}
if (right.type === DiffType.ADDED) {
content = this.renderLine(null, right.type, LineNumberPrefix.RIGHT, rightValue, right.lineNumber);
}
return (_jsx("tr", { className: this.styles.line, children: content }, index));
};
/**
* Returns a function with clicked block number in the closure.
*
* @param id Cold fold block id.
*/
this.onBlockClickProxy = (id) => () => this.onBlockExpand(id);
/**
* Generates cold fold block. It also uses the custom message renderer when available to show
* cold fold messages.
*
* @param num Number of skipped lines between two blocks.
* @param blockNumber Code fold block id.
* @param leftBlockLineNumber First left line number after the current code fold block.
* @param rightBlockLineNumber First right line number after the current code fold block.
*/
this.renderSkippedLineIndicator = (num, blockNumber, leftBlockLineNumber, rightBlockLineNumber) => {
const { hideLineNumbers, splitView } = this.props;
const message = this.props.codeFoldMessageRenderer ? (this.props.codeFoldMessageRenderer(num, leftBlockLineNumber, rightBlockLineNumber)) : (_jsxs("span", { className: this.styles.codeFoldContent, children: ["@@ -", leftBlockLineNumber - num, ",", num, " +", rightBlockLineNumber - num, ",", num, " @@"] }));
const content = (_jsx("td", { className: this.styles.codeFoldContentContainer, children: _jsx("button", { type: "button", className: this.styles.codeFoldExpandButton, onClick: this.onBlockClickProxy(blockNumber), tabIndex: 0, children: message }) }));
const isUnifiedViewWithoutLineNumbers = !splitView && !hideLineNumbers;
const expandGutter = (_jsx("td", { className: this.styles.codeFoldGutter, children: _jsx(Expand, {}) }));
return (_jsxs("tr", { className: this.styles.codeFold, onClick: this.onBlockClickProxy(blockNumber), role: "button", tabIndex: 0, children: [!hideLineNumbers && expandGutter, this.props.renderGutter ? (_jsx("td", { className: this.styles.codeFoldGutter })) : null, _jsx("td", { className: cn({
[this.styles.codeFoldGutter]: isUnifiedViewWithoutLineNumbers,
}) }), isUnifiedViewWithoutLineNumbers ? (_jsxs(React.Fragment, { children: [_jsx("td", {}), content] })) : (_jsxs(React.Fragment, { children: [content, this.props.renderGutter ? _jsx("td", {}) : null, _jsx("td", {}), _jsx("td", {}), !hideLineNumbers ? _jsx("td", {}) : null] }))] }, `${leftBlockLineNumber}-${rightBlockLineNumber}`));
};
/**
*
* Generates a unique cache key based on the current props used in diff computation.
*
* This key is used to memoize results and avoid recomputation for the same inputs.
* @returns A stringified JSON key representing the current diff settings and input values.
*
*/
this.getMemoisedKey = () => {
const { oldValue, newValue, disableWordDiff, compareMethod, linesOffset, alwaysShowLines, extraLinesSurroundingDiff, } = this.props;
return JSON.stringify({
oldValue,
newValue,
disableWordDiff,
compareMethod,
linesOffset,
alwaysShowLines,
extraLinesSurroundingDiff,
});
};
/**
* Computes and memoizes the diff result between `oldValue` and `newValue`.
*
* If a memoized result exists for the current input configuration, it uses that.
* Otherwise, it runs the diff logic in a Web Worker to avoid blocking the UI.
* It also computes hidden line blocks for collapsing unchanged sections,
* and stores the result in the local component state.
*/
this.memoisedCompute = () => __awaiter(this, void 0, void 0, function* () {
var _a;
const { oldValue, newValue, disableWordDiff, compareMethod, linesOffset } = this.props;
const cacheKey = this.getMemoisedKey();
if (!!this.state.computedDiffResult[cacheKey]) {
this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: false })));
return;
}
// Defer word diff computation when using infinite loading with reasonable container height
// This significantly improves initial render time for large diffs
const containerHeight = (_a = this.props.infiniteLoading) === null || _a === void 0 ? void 0 : _a.containerHeight;
const containerHeightPx = containerHeight
? typeof containerHeight === 'number'
? containerHeight
: parseInt(containerHeight, 10) || 0
: 0;
const shouldDeferWordDiff = !disableWordDiff &&
!!this.props.infiniteLoading &&
containerHeightPx > 0 &&
containerHeightPx < 2000;
const { lineInformation, diffLines } = yield computeLineInformationWorker(oldValue, newValue, disableWordDiff, compareMethod, linesOffset, this.props.alwaysShowLines, shouldDeferWordDiff);
const extraLines = this.props.extraLinesSurroundingDiff < 0
? 0
: Math.round(this.props.extraLinesSurroundingDiff);
const { lineBlocks, blocks } = computeHiddenBlocks(lineInformation, diffLines, extraLines);
this.state.computedDiffResult[cacheKey] = { lineInformation, lineBlocks, blocks };
this.setState((prev) => (Object.assign(Object.assign({}, prev), { computedDiffResult: this.state.computedDiffResult, isLoading: false })), () => {
// Trigger offset recalculation after diff is computed and rendered
// Use requestAnimationFrame to ensure DOM is ready for measurement
if (this.props.infiniteLoading) {
requestAnimationFrame(() => this.recalculateOffsets());
}
});
});
/**
* Handles scroll events on the scrollable container.
*
* Updates the visible start row for virtualization.
*/
this.onScroll = () => {
const container = this.state.scrollableContainerRef.current;
if (!container || !this.props.infiniteLoading)
return;
// Account for sticky header height in scroll calculations
const headerHeight = this.getStickyHeaderHeight();
const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
const { cumulativeOffsets } = this.state;
const newStartRow = cumulativeOffsets
? this.findLineAtOffset(contentScrollTop, cumulativeOffsets)
: Math.floor(contentScrollTop / DiffViewer.ESTIMATED_ROW_HEIGHT);
// Only update state if the start row changed (avoid unnecessary re-renders)
if (newStartRow !== this.state.visibleStartRow) {
this.setState({ visibleStartRow: newStartRow });
}
};
/**
* Generates the entire diff view with virtualization support.
*/
this.renderDiff = () => {
var _a, _b, _c, _d, _e, _f;
const { splitView, infiniteLoading, showDiffOnly } = this.props;
const { computedDiffResult, expandedBlocks, visibleStartRow, scrollableContainerRef, cumulativeOffsets } = this.state;
const cacheKey = this.getMemoisedKey();
const { lineInformation = [], lineBlocks = [], blocks = [] } = (_a = computedDiffResult[cacheKey]) !== null && _a !== void 0 ? _a : {};
// Calculate visible range for virtualization
let visibleRowStart = 0;
let visibleRowEnd = Infinity;
const buffer = 5; // render extra rows above/below viewport
if (infiniteLoading && scrollableContainerRef.current) {
const container = scrollableContainerRef.current;
// Account for sticky header height in scroll calculations
const headerHeight = this.getStickyHeaderHeight();
const contentScrollTop = Math.max(0, container.scrollTop - headerHeight);
if (cumulativeOffsets) {
// Variable height mode: use binary search to find visible range
const totalHeight = cumulativeOffsets[cumulativeOffsets.length - 1] || 0;
const lastRowIndex = cumulativeOffsets.length - 2;
visibleRowStart = Math.max(0, this.findLineAtOffset(contentScrollTop, cumulativeOffsets) - buffer);
visibleRowEnd = this.findLineAtOffset(contentScrollTop + container.clientHeight, cumulativeOffsets) + buffer;
// IMPORTANT: The calculated offsets may overestimate row heights (based on char count),
// but actual CSS rendering might produce shorter rows. To prevent empty space,
// ensure we render at least enough rows to fill the viewport using ESTIMATED_ROW_HEIGHT
// as a conservative minimum.
const minRowsToFillViewport = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
visibleRowEnd = Math.max(visibleRowEnd, visibleRowStart + minRowsToFillViewport + buffer);
// Also ensure we render all rows when near the bottom
if (contentScrollTop + container.clientHeight >= totalHeight - buffer * DiffViewer.ESTIMATED_ROW_HEIGHT) {
visibleRowEnd = lastRowIndex + buffer;
}
}
else {
// Fixed height fallback
const viewportRows = Math.ceil(container.clientHeight / DiffViewer.ESTIMATED_ROW_HEIGHT);
visibleRowStart = Math.max(0, visibleStartRow - buffer);
visibleRowEnd = visibleStartRow + viewportRows + buffer;
}
}
// First pass: build a map of lineIndex -> renderedRowIndex
// This accounts for code folding where some lines don't render or render as fold indicators
const lineToRowMap = new Map();
const seenBlocks = new Set();
let currentRow = 0;
for (let i = 0; i < lineInformation.length; i++) {
const blockIndex = lineBlocks[i];
if (showDiffOnly && blockIndex !== undefined) {
if (!expandedBlocks.includes(blockIndex)) {
// Line is in a collapsed block
const lastLineOfBlock = blocks[blockIndex].endLine === i;
if (!seenBlocks.has(blockIndex) && lastLineOfBlock) {
// This line renders as a fold indicator
seenBlocks.add(blockIndex);
lineToRowMap.set(i, currentRow);
currentRow++;
}
// Other lines in collapsed block don't render
}
else {
// Block is expanded, line renders normally
lineToRowMap.set(i, currentRow);
currentRow++;
}
}
else {
// Not in a block or showDiffOnly is false, line renders normally
lineToRowMap.set(i, currentRow);
currentRow++;
}
}
const totalRenderedRows = currentRow;
// Second pass: render only lines in the visible range
const diffNodes = [];
let topPadding = 0;
let firstVisibleFound = false;
let lastRenderedRowIndex = -1;
seenBlocks.clear();
for (let lineIndex = 0; lineIndex < lineInformation.length; lineIndex++) {
const line = lineInformation[lineIndex];
const rowIndex = lineToRowMap.get(lineIndex);
// Skip lines that don't render (hidden in collapsed blocks)
if (rowIndex === undefined)
continue;
// Skip lines before visible range
if (rowIndex < visibleRowStart) {
continue;
}
// Stop after visible range
if (rowIndex > visibleRowEnd) {
break;
}
// Calculate top padding from the first visible row
if (!firstVisibleFound) {
topPadding = cumulativeOffsets
? cumulativeOffsets[rowIndex] || 0
: rowIndex * DiffViewer.ESTIMATED_ROW_HEIGHT;
firstVisibleFound = true;
}
// Track the last rendered row for bottom padding calculation
lastRenderedRowIndex = rowIndex;
// Render the line
if (showDiffOnly) {
const blockIndex = lineBlocks[lineIndex];
if (blockIndex !== undefined) {
const lastLineOfBlock = blocks[blockIndex].endLine === lineIndex;
if (!expandedBlocks.includes(blockIndex) &&
lastLineOfBlock) {
diffNodes.push(_jsx(React.Fragment, { children: this.renderSkippedLineIndicator(blocks[blockIndex].lines, blockIndex, line.left.lineNumber, line.right.lineNumber) }, lineIndex));
continue;
}
if (!expandedBlocks.includes(blockIndex)) {
continue;
}
}
}
diffNodes.push(splitView
? this.renderSplitView(line, lineIndex)
: this.renderInlineView(line, lineIndex));
}
// Calculate total content height
const totalContentHeight = cumulativeOffsets
? cumulativeOffsets[cumulativeOffsets.length - 1] || 0
: totalRenderedRows * DiffViewer.ESTIMATED_ROW_HEIGHT;
// Calculate bottom padding: space after the last rendered row
const bottomPadding = cumulativeOffsets && lastRenderedRowIndex >= 0
? totalContentHeight - (cumulativeOffsets[lastRenderedRowIndex + 1] || totalContentHeight)
: 0;
return {
diffNodes,
blocks,
lineInformation,
totalRenderedRows,
topPadding,
bottomPadding,
totalContentHeight,
renderedCount: diffNodes.length,
// Debug info
debug: {
visibleRowStart,
visibleRowEnd,
totalRows: totalRenderedRows,
offsetsLength: (_b = cumulativeOffsets === null || cumulativeOffsets === void 0 ? void 0 : cumulativeOffsets.length) !== null && _b !== void 0 ? _b : 0,
renderedCount: diffNodes.length,
scrollTop: (_d = (_c = scrollableContainerRef.current) === null || _c === void 0 ? void 0 : _c.scrollTop) !== null && _d !== void 0 ? _d : 0,
headerHeight: this.getStickyHeaderHeight(),
contentScrollTop: scrollableContainerRef.current
? Math.max(0, scrollableContainerRef.current.scrollTop - this.getStickyHeaderHeight())
: 0,
clientHeight: (_f = (_e = scrollableContainerRef.current) === null || _e === void 0 ? void 0 : _e.clientHeight) !== null && _f !== void 0 ? _f : 0,
}
};
};
this.render = () => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
const { oldValue, newValue, useDarkTheme, leftTitle, rightTitle, splitView, compareMethod, hideLineNumbers, nonce, } = this.props;
if (typeof compareMethod === "string" &&
compareMethod !== DiffMethod.JSON) {
if (typeof oldValue !== "string" || typeof newValue !== "string") {
throw Error('"oldValue" and "newValue" should be strings');
}
}
this.styles = this.computeStyles(this.props.styles, useDarkTheme, nonce);
const nodes = this.renderDiff();
let colSpanOnSplitView = 3;
let colSpanOnInlineView = 4;
if (hideLineNumbers) {
colSpanOnSplitView -= 1;
colSpanOnInlineView -= 1;
}
if (this.props.renderGutter) {
colSpanOnSplitView += 1;
colSpanOnInlineView += 1;
}
let deletions = 0;
let additions = 0;
for (const l of nodes.lineInformation) {
if (l.left.type === DiffType.ADDED) {
additions++;
}
if (l.right.type === DiffType.ADDED) {
additions++;
}
if (l.left.type === DiffType.REMOVED) {
deletions++;
}
if (l.right.type === DiffType.REMOVED) {
deletions++;
}
}
const totalChanges = deletions + additions;
const percentageAddition = Math.round((additions / totalChanges) * 100);
const blocks = [];
for (let i = 0; i < 5; i++) {
if (percentageAddition > i * 20) {
blocks.push(_jsx("span", { className: cn(this.styles.block, this.styles.blockAddition) }, i));
}
else {
blocks.push(_jsx("span", { className: cn(this.styles.block, this.styles.blockDeletion) }, i));
}
}
const allExpanded = this.state.expandedBlocks.length === nodes.blocks.length;
const LoadingElement = this.props.loadingElement;
const scrollDivStyle = this.props.infiniteLoading ? {
overflowY: 'scroll',
overflowX: 'hidden',
height: this.props.infiniteLoading.containerHeight
} : {};
// Only apply noWrap when infiniteLoading is enabled but we don't have cumulative offsets yet
// Once offsets are calculated, we enable pre-wrap for proper text wrapping
const shouldNoWrap = !!this.props.infiniteLoading && !this.state.cumulativeOffsets;
const tableElement = (_jsxs("table", { className: cn(this.styles.diffContainer, {
[this.styles.splitView]: splitView,
[this.styles.noWrap]: shouldNoWrap,
}), onMouseUp: () => {
const elements = document.getElementsByClassName("right");
for (let i = 0; i < elements.length; i++) {
const element = elements.item(i);
element.classList.remove(this.styles.noSelect);
}
const elementsLeft = document.getElementsByClassName("left");
for (let i = 0; i < elementsLeft.length; i++) {
const element = elementsLeft.item(i);
element.classList.remove(this.styles.noSelect);
}
}, 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 })] }));
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: () => {
this.setState({
expandedBlocks: allExpanded
? []
: nodes.blocks.map((b) => b.index),
}, () => this.recalculateOffsets());
}, 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: {
height: nodes.totalContentHeight,
position: 'relative',
}, children: _jsx("div", { style: {
position: 'absolute',
top: nodes.topPadding,
left: 0,
right: 0,
}, children: tableElement }) })) : (tableElement), _jsx("span", { ref: this.charMeasureRef, style: {
position: 'absolute',
top: 0,
left: '-9999px',
visibility: 'hidden',
whiteSpace: 'pre',
fontFamily: 'monospace',
fontSize: 12,
}, "aria-hidden": "true", children: "M" }), this.props.infiniteLoading && this.props.showDebugInfo && (_jsxs("div", { style: {
position: 'fixed',
top: 10,
right: 10,
background: 'rgba(0,0,0,0.85)',
color: '#0f0',
padding: '10px',
fontFamily: 'monospace',
fontSize: '11px',
zIndex: 9999,
borderRadius: '4px',
maxWidth: '300px',
lineHeight: 1.4,
}, 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 = thi