react-diff-viewer-continued 4.2.2 → 4.4.0

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.
@@ -3,7 +3,9 @@ import type { ReactElement, RefObject } from "react";
3
3
  import type { Change } from "diff";
4
4
  import { type Block } from "./compute-hidden-blocks.js";
5
5
  import { type DiffInformation, DiffMethod, DiffType, type LineInformation } from "./compute-lines.js";
6
- import { type ReactDiffViewerStyles, type ReactDiffViewerStylesOverride } from "./styles.js";
6
+ import { type ReactDiffViewerStyles, type ReactDiffViewerStylesOverride, type ReactDiffViewerStylesVariables } from "./styles.js";
7
+ import { type LineTokens } from "./highlight.js";
8
+ import { type HighlightTheme } from "./highlight-theme.js";
7
9
  export declare enum LineNumberPrefix {
8
10
  LEFT = "L",
9
11
  RIGHT = "R"
@@ -75,11 +77,26 @@ export interface ReactDiffViewerProps {
75
77
  * Useful when the worker bundle fails to load in certain bundler configurations.
76
78
  */
77
79
  disableWorker?: boolean;
80
+ /**
81
+ * Enable first-party syntax highlighting for the given language (a Prism/
82
+ * refractor language name or alias, e.g. `"typescript"`, `"json"`, `"html"`).
83
+ * Each side is highlighted as a whole — so constructs that wrap across lines
84
+ * tokenise correctly — and the highlighting is merged with word-diff marks on
85
+ * changed lines. Takes precedence over `renderContent` for line content.
86
+ * Unknown languages fall back gracefully to no highlighting.
87
+ */
88
+ highlightLanguage?: string;
89
+ /**
90
+ * Optional overrides for the syntax-highlight colour palette. Merged over the
91
+ * built-in light/dark theme (selected by `useDarkTheme`). Keys are Prism token
92
+ * types (`keyword`, `string`, `tag`, ...) plus `default`.
93
+ */
94
+ highlightTheme?: HighlightTheme;
78
95
  }
79
96
  export interface ReactDiffViewerState {
80
97
  expandedBlocks: number[];
81
98
  noSelect?: "left" | "right";
82
- scrollableContainerRef: RefObject<HTMLDivElement | null>;
99
+ scrollableContainerRef: RefObject<HTMLDivElement>;
83
100
  computedDiffResult: Record<string, ComputedDiffResult>;
84
101
  isLoading: boolean;
85
102
  visibleStartRow: number;
@@ -87,10 +104,16 @@ export interface ReactDiffViewerState {
87
104
  charWidth: number | null;
88
105
  cumulativeOffsets: number[] | null;
89
106
  isScrolling: boolean;
107
+ highlightResult: {
108
+ key: string;
109
+ left: Map<number, LineTokens>;
110
+ right: Map<number, LineTokens>;
111
+ } | null;
90
112
  }
91
113
  declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiffViewerState> {
92
114
  private styles;
93
115
  private wordDiffCache;
116
+ private highlightPrecedenceWarned;
94
117
  private contentColumnRef;
95
118
  private charMeasureRef;
96
119
  private stickyHeaderRef;
@@ -239,6 +262,31 @@ declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiff
239
262
  * and stores the result in the local component state.
240
263
  */
241
264
  private memoisedCompute;
265
+ /**
266
+ * Extracts the raw text of one side of a line for whole-side highlighting.
267
+ * Prefers `rawValue` (deferred word diff), then a plain string value, then the
268
+ * concatenation of word-diff chunk values.
269
+ */
270
+ private lineToText;
271
+ /**
272
+ * Reconstructs one side's full document from the computed line information and
273
+ * highlights it in a single pass, returning per-line tokens keyed by line
274
+ * number. Highlighting the whole side (rather than line-by-line) is what keeps
275
+ * lexer state correct across line boundaries.
276
+ */
277
+ private buildSideTokens;
278
+ /**
279
+ * Resolves the active highlight theme (built-in light/dark, with any
280
+ * `highlightTheme` overrides merged on top).
281
+ */
282
+ private resolveHighlightTheme;
283
+ /**
284
+ * Computes syntax-highlight tokens for both sides when `highlightLanguage` is
285
+ * set, storing them in state. Lazily loads the grammar, is memoised by
286
+ * (diff, language, theme, dark) so it is cheap to call repeatedly, and clears
287
+ * the result when highlighting is disabled or the language is unavailable.
288
+ */
289
+ private updateHighlight;
242
290
  private static readonly ESTIMATED_ROW_HEIGHT;
243
291
  /**
244
292
  * Handles scroll events on the scrollable container.
@@ -258,4 +306,7 @@ declare class DiffViewer extends React.Component<ReactDiffViewerProps, ReactDiff
258
306
  export default DiffViewer;
259
307
  export { DiffMethod };
260
308
  export { default as computeStyles } from "./styles.js";
261
- export type { ReactDiffViewerStylesOverride, ReactDiffViewerStyles };
309
+ export { defaultLightThemeVariables, defaultDarkThemeVariables, } from "./styles.js";
310
+ export { defaultLightHighlightTheme, defaultDarkHighlightTheme, } from "./highlight-theme.js";
311
+ export type { ReactDiffViewerStylesOverride, ReactDiffViewerStyles, ReactDiffViewerStylesVariables };
312
+ export type { HighlightTheme } from "./highlight-theme.js";
@@ -7,6 +7,56 @@ import { DiffMethod, DiffType, computeLineInformationWorker, computeDiff, } from
7
7
  import { Expand } from "./expand.js";
8
8
  import computeStyles from "./styles.js";
9
9
  import { Fold } from "./fold.js";
10
+ import { ensureLanguage, highlightSide, mergeHighlightWithDiff, renderHighlightedPlain, sliceLineTokens, } from "./highlight.js";
11
+ import { defaultDarkHighlightTheme, defaultLightHighlightTheme, } from "./highlight-theme.js";
12
+ const LEADING_WHITESPACE = /^[ \t]+/;
13
+ /**
14
+ * Splits a line's leading whitespace off its content so it can be rendered as a
15
+ * separate fixed-width indent column (see `contentFlex`/`lineIndent`/`lineBody`
16
+ * styles). Peeling happens *before* the value is highlighted/word-diffed, so the
17
+ * indent never gets swept into a syntax token or a diff chunk.
18
+ *
19
+ * Handles both shapes `renderLine` receives: a raw string, or a word-diff array
20
+ * whose leading whitespace may span one or more leading chunks (WORDS_WITH_SPACE
21
+ * isolates indentation as its own chunk) or prefix the first chunk. The remaining
22
+ * chunks are returned intact so their diff types/colours are preserved.
23
+ */
24
+ function splitLeadingWhitespace(value) {
25
+ if (typeof value === "string") {
26
+ const match = value.match(LEADING_WHITESPACE);
27
+ return match
28
+ ? { indent: match[0], rest: value.slice(match[0].length) }
29
+ : { indent: "", rest: value };
30
+ }
31
+ if (Array.isArray(value)) {
32
+ const chunks = value.map((chunk) => (Object.assign({}, chunk)));
33
+ let indent = "";
34
+ let i = 0;
35
+ for (; i < chunks.length; i++) {
36
+ const chunkValue = typeof chunks[i].value === "string"
37
+ ? chunks[i].value
38
+ : null;
39
+ if (chunkValue === null)
40
+ break;
41
+ const match = chunkValue.match(LEADING_WHITESPACE);
42
+ if (!match)
43
+ break;
44
+ if (match[0].length === chunkValue.length) {
45
+ // Whole chunk is whitespace — consume it and keep scanning.
46
+ indent += chunkValue;
47
+ continue;
48
+ }
49
+ // Chunk starts with whitespace then real content — keep the trimmed remainder.
50
+ indent += match[0];
51
+ chunks[i] = Object.assign(Object.assign({}, chunks[i]), { value: chunkValue.slice(match[0].length) });
52
+ break;
53
+ }
54
+ return indent
55
+ ? { indent, rest: chunks.slice(i) }
56
+ : { indent: "", rest: value };
57
+ }
58
+ return { indent: "", rest: value };
59
+ }
10
60
  /**
11
61
  * Applies diff styling (ins/del tags) to pre-highlighted HTML by walking through
12
62
  * the HTML and wrapping text portions based on character positions in the diff.
@@ -154,6 +204,8 @@ class DiffViewer extends React.Component {
154
204
  super(props);
155
205
  // Cache for on-demand word diff computation
156
206
  this.wordDiffCache = new Map();
207
+ // Ensures the highlightLanguage/renderContent precedence warning fires once.
208
+ this.highlightPrecedenceWarned = false;
157
209
  // Refs for measuring content column width and character width
158
210
  this.contentColumnRef = React.createRef();
159
211
  this.charMeasureRef = React.createRef();
@@ -334,7 +386,7 @@ class DiffViewer extends React.Component {
334
386
  * @param additionalPrefix Similar to prefix but for additional line number.
335
387
  */
336
388
  this.renderLine = (lineNumber, type, prefix, value, additionalLineNumber, additionalPrefix) => {
337
- var _a;
389
+ var _a, _b;
338
390
  const lineNumberTemplate = `${prefix}-${lineNumber}`;
339
391
  const additionalLineNumberTemplate = `${additionalPrefix}-${additionalLineNumber}`;
340
392
  const highlightLines = (_a = this.props.highlightLines) !== null && _a !== void 0 ? _a : [];
@@ -343,16 +395,56 @@ class DiffViewer extends React.Component {
343
395
  const added = type === DiffType.ADDED;
344
396
  const removed = type === DiffType.REMOVED;
345
397
  const changed = type === DiffType.CHANGED;
398
+ // Peel the leading whitespace into a separate indent column before any
399
+ // highlighting/word-diffing runs, so wrapped continuation lines hang-indent
400
+ // under the line's first character instead of the cell's left edge.
401
+ const { indent, rest } = splitLeadingWhitespace(value);
402
+ const hasWordDiff = Array.isArray(rest);
403
+ // First-party syntax highlighting: look up this line's whole-side tokens and
404
+ // rebase them past the peeled indent so they line up with the diff body.
405
+ const highlightMap = this.state.highlightResult
406
+ ? prefix === LineNumberPrefix.LEFT
407
+ ? this.state.highlightResult.left
408
+ : this.state.highlightResult.right
409
+ : null;
410
+ const sideLineNumber = (_b = lineNumber !== null && lineNumber !== void 0 ? lineNumber : additionalLineNumber) !== null && _b !== void 0 ? _b : undefined;
411
+ const fullLineTokens = highlightMap && sideLineNumber != null ? highlightMap.get(sideLineNumber) : undefined;
412
+ const bodyTokens = fullLineTokens ? sliceLineTokens(fullLineTokens, indent.length) : undefined;
413
+ // Longest body we'll merge char-by-char before falling back to plain colour.
414
+ const MAX_HIGHLIGHT_MERGE_LENGTH = 500;
346
415
  let content;
347
- const hasWordDiff = Array.isArray(value);
348
- if (hasWordDiff) {
349
- content = this.renderWordDiff(value, this.props.renderContent);
416
+ if (bodyTokens) {
417
+ if (hasWordDiff) {
418
+ const bodyText = rest
419
+ .map((chunk) => (typeof chunk.value === "string" ? chunk.value : ""))
420
+ .join("");
421
+ content =
422
+ bodyText.length > MAX_HIGHLIGHT_MERGE_LENGTH
423
+ ? renderHighlightedPlain(bodyText, bodyTokens)
424
+ : mergeHighlightWithDiff(bodyText, bodyTokens, rest, {
425
+ styles: {
426
+ wordDiff: this.styles.wordDiff,
427
+ wordAdded: this.styles.wordAdded,
428
+ wordRemoved: this.styles.wordRemoved,
429
+ },
430
+ showHighlight: this.shouldHighlightWordDiff(),
431
+ });
432
+ }
433
+ else if (typeof rest === "string") {
434
+ content = renderHighlightedPlain(rest, bodyTokens);
435
+ }
436
+ else {
437
+ content = rest;
438
+ }
439
+ }
440
+ else if (hasWordDiff) {
441
+ content = this.renderWordDiff(rest, this.props.renderContent);
350
442
  }
351
- else if (this.props.renderContent && typeof value === "string") {
352
- content = this.props.renderContent(value);
443
+ else if (this.props.renderContent && typeof rest === "string") {
444
+ content = this.props.renderContent(rest);
353
445
  }
354
446
  else {
355
- content = value;
447
+ content = rest;
356
448
  }
357
449
  let ElementType = "div";
358
450
  if (added && !hasWordDiff) {
@@ -361,6 +453,9 @@ class DiffViewer extends React.Component {
361
453
  else if (removed && !hasWordDiff) {
362
454
  ElementType = "del";
363
455
  }
456
+ // A line is only "empty" (gets the empty-line background) when it has neither
457
+ // body content nor peeled indentation — a whitespace-only line is not empty.
458
+ const isEmpty = !content && !indent;
364
459
  return (_jsxs(_Fragment, { children: [!this.props.hideLineNumbers && (_jsx("td", { onClick: lineNumber && this.onLineNumberClickProxy(lineNumberTemplate), className: cn(this.styles.gutter, {
365
460
  [this.styles.emptyGutter]: !lineNumber,
366
461
  [this.styles.diffAdded]: added,
@@ -385,13 +480,13 @@ class DiffViewer extends React.Component {
385
480
  styles: this.styles,
386
481
  })
387
482
  : null, _jsx("td", { className: cn(this.styles.marker, {
388
- [this.styles.emptyLine]: !content,
483
+ [this.styles.emptyLine]: isEmpty,
389
484
  [this.styles.diffAdded]: added,
390
485
  [this.styles.diffRemoved]: removed,
391
486
  [this.styles.diffChanged]: changed,
392
487
  [this.styles.highlightedLine]: highlightLine,
393
488
  }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { ref: prefix === LineNumberPrefix.LEFT && !this.state.cumulativeOffsets ? this.contentColumnRef : undefined, className: cn(this.styles.content, {
394
- [this.styles.emptyLine]: !content,
489
+ [this.styles.emptyLine]: isEmpty,
395
490
  [this.styles.diffAdded]: added,
396
491
  [this.styles.diffRemoved]: removed,
397
492
  [this.styles.diffChanged]: changed,
@@ -416,7 +511,7 @@ class DiffViewer extends React.Component {
416
511
  ? "Added line"
417
512
  : removed && !hasWordDiff
418
513
  ? "Removed line"
419
- : undefined, children: _jsx(ElementType, { className: this.styles.contentText, children: content }) })] }));
514
+ : undefined, children: _jsxs(ElementType, { className: cn(this.styles.contentText, this.styles.contentFlex), children: [indent ? _jsx("span", { className: this.styles.lineIndent, children: indent }) : null, _jsx("span", { className: this.styles.lineBody, children: content })] }) })] }));
420
515
  };
421
516
  /**
422
517
  * Generates lines for split view.
@@ -516,6 +611,7 @@ class DiffViewer extends React.Component {
516
611
  const cacheKey = this.getMemoisedKey();
517
612
  if (!!this.state.computedDiffResult[cacheKey]) {
518
613
  this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: false })));
614
+ this.updateHighlight();
519
615
  return;
520
616
  }
521
617
  // Defer word diff computation when using infinite loading with reasonable container height
@@ -536,6 +632,8 @@ class DiffViewer extends React.Component {
536
632
  const { lineBlocks, blocks } = computeHiddenBlocks(lineInformation, diffLines, extraLines);
537
633
  this.state.computedDiffResult[cacheKey] = { lineInformation, lineBlocks, blocks };
538
634
  this.setState((prev) => (Object.assign(Object.assign({}, prev), { computedDiffResult: this.state.computedDiffResult, isLoading: false })), () => {
635
+ // Recompute syntax highlighting now that fresh line information exists.
636
+ this.updateHighlight();
539
637
  // Trigger offset recalculation after diff is computed and rendered
540
638
  // Use requestAnimationFrame to ensure DOM is ready for measurement
541
639
  if (this.props.infiniteLoading) {
@@ -543,6 +641,100 @@ class DiffViewer extends React.Component {
543
641
  }
544
642
  });
545
643
  };
644
+ /**
645
+ * Extracts the raw text of one side of a line for whole-side highlighting.
646
+ * Prefers `rawValue` (deferred word diff), then a plain string value, then the
647
+ * concatenation of word-diff chunk values.
648
+ */
649
+ this.lineToText = (info) => {
650
+ if (typeof info.rawValue === "string")
651
+ return info.rawValue;
652
+ if (typeof info.value === "string")
653
+ return info.value;
654
+ if (Array.isArray(info.value)) {
655
+ return info.value.map((chunk) => (typeof chunk.value === "string" ? chunk.value : "")).join("");
656
+ }
657
+ return "";
658
+ };
659
+ /**
660
+ * Reconstructs one side's full document from the computed line information and
661
+ * highlights it in a single pass, returning per-line tokens keyed by line
662
+ * number. Highlighting the whole side (rather than line-by-line) is what keeps
663
+ * lexer state correct across line boundaries.
664
+ */
665
+ this.buildSideTokens = (lineInformation, side, language, theme) => {
666
+ var _a;
667
+ const entries = [];
668
+ for (const line of lineInformation) {
669
+ const info = line[side];
670
+ if (!info || info.lineNumber == null)
671
+ continue;
672
+ entries.push({ lineNumber: info.lineNumber, text: this.lineToText(info) });
673
+ }
674
+ entries.sort((a, b) => a.lineNumber - b.lineNumber);
675
+ const result = new Map();
676
+ if (entries.length === 0)
677
+ return result;
678
+ const perLine = highlightSide(entries.map((e) => e.text).join("\n"), language, theme);
679
+ if (!perLine)
680
+ return result;
681
+ for (let i = 0; i < entries.length; i++) {
682
+ result.set(entries[i].lineNumber, (_a = perLine[i]) !== null && _a !== void 0 ? _a : []);
683
+ }
684
+ return result;
685
+ };
686
+ /**
687
+ * Resolves the active highlight theme (built-in light/dark, with any
688
+ * `highlightTheme` overrides merged on top).
689
+ */
690
+ this.resolveHighlightTheme = () => {
691
+ const base = this.props.useDarkTheme ? defaultDarkHighlightTheme : defaultLightHighlightTheme;
692
+ return this.props.highlightTheme ? Object.assign(Object.assign({}, base), this.props.highlightTheme) : base;
693
+ };
694
+ /**
695
+ * Computes syntax-highlight tokens for both sides when `highlightLanguage` is
696
+ * set, storing them in state. Lazily loads the grammar, is memoised by
697
+ * (diff, language, theme, dark) so it is cheap to call repeatedly, and clears
698
+ * the result when highlighting is disabled or the language is unavailable.
699
+ */
700
+ this.updateHighlight = async () => {
701
+ var _a, _b, _c, _d;
702
+ const { highlightLanguage } = this.props;
703
+ if (!highlightLanguage) {
704
+ if (this.state.highlightResult)
705
+ this.setState({ highlightResult: null });
706
+ return;
707
+ }
708
+ const diffKey = this.getMemoisedKey();
709
+ const diffResult = this.state.computedDiffResult[diffKey];
710
+ // Diff not ready yet — memoisedCompute re-invokes updateHighlight when it is.
711
+ if (!diffResult)
712
+ return;
713
+ const dark = (_a = this.props.useDarkTheme) !== null && _a !== void 0 ? _a : false;
714
+ const key = `${diffKey}::${highlightLanguage}::${dark}::${JSON.stringify((_b = this.props.highlightTheme) !== null && _b !== void 0 ? _b : null)}`;
715
+ if (((_c = this.state.highlightResult) === null || _c === void 0 ? void 0 : _c.key) === key)
716
+ return;
717
+ const canonical = await ensureLanguage(highlightLanguage);
718
+ // Bail if props changed while the grammar was loading.
719
+ if (this.props.highlightLanguage !== highlightLanguage || this.getMemoisedKey() !== diffKey)
720
+ return;
721
+ if (!canonical) {
722
+ if (this.state.highlightResult)
723
+ this.setState({ highlightResult: null });
724
+ return;
725
+ }
726
+ if (!this.highlightPrecedenceWarned &&
727
+ this.props.renderContent &&
728
+ typeof process !== "undefined" &&
729
+ ((_d = process.env) === null || _d === void 0 ? void 0 : _d.NODE_ENV) !== "production") {
730
+ this.highlightPrecedenceWarned = true;
731
+ console.warn("[react-diff-viewer] `highlightLanguage` takes precedence over `renderContent`; `renderContent` is ignored for line content while highlighting is active.");
732
+ }
733
+ const theme = this.resolveHighlightTheme();
734
+ const left = this.buildSideTokens(diffResult.lineInformation, "left", canonical, theme);
735
+ const right = this.buildSideTokens(diffResult.lineInformation, "right", canonical, theme);
736
+ this.setState({ highlightResult: { key, left, right } });
737
+ };
546
738
  /**
547
739
  * Handles scroll events on the scrollable container.
548
740
  *
@@ -855,6 +1047,7 @@ class DiffViewer extends React.Component {
855
1047
  charWidth: null,
856
1048
  cumulativeOffsets: null,
857
1049
  isScrolling: false,
1050
+ highlightResult: null,
858
1051
  };
859
1052
  }
860
1053
  /**
@@ -978,6 +1171,12 @@ class DiffViewer extends React.Component {
978
1171
  this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true, visibleStartRow: 0, cumulativeOffsets: null })));
979
1172
  this.memoisedCompute();
980
1173
  }
1174
+ else if (prevProps.highlightLanguage !== this.props.highlightLanguage ||
1175
+ prevProps.highlightTheme !== this.props.highlightTheme ||
1176
+ prevProps.useDarkTheme !== this.props.useDarkTheme) {
1177
+ // Highlighting inputs changed but the diff itself did not — just re-tokenise.
1178
+ this.updateHighlight();
1179
+ }
981
1180
  }
982
1181
  componentDidMount() {
983
1182
  this.setState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true })));
@@ -1021,3 +1220,5 @@ DiffViewer.ESTIMATED_ROW_HEIGHT = 19;
1021
1220
  export default DiffViewer;
1022
1221
  export { DiffMethod };
1023
1222
  export { default as computeStyles } from "./styles.js";
1223
+ export { defaultLightThemeVariables, defaultDarkThemeVariables, } from "./styles.js";
1224
+ export { defaultLightHighlightTheme, defaultDarkHighlightTheme, } from "./highlight-theme.js";
@@ -11,6 +11,9 @@ export interface ReactDiffViewerStyles {
11
11
  gutter: string;
12
12
  highlightedLine: string;
13
13
  lineNumber: string;
14
+ contentFlex: string;
15
+ lineIndent: string;
16
+ lineBody: string;
14
17
  marker: string;
15
18
  wordDiff: string;
16
19
  wordAdded: string;
@@ -98,5 +101,7 @@ export interface ReactDiffViewerStylesOverride {
98
101
  splitView?: Interpolation;
99
102
  allExpandButton?: Interpolation;
100
103
  }
104
+ export declare const defaultLightThemeVariables: Readonly<Required<ReactDiffViewerStylesVariables>>;
105
+ export declare const defaultDarkThemeVariables: Readonly<Required<ReactDiffViewerStylesVariables>>;
101
106
  declare const _default: (styleOverride: ReactDiffViewerStylesOverride, useDarkTheme?: boolean, nonce?: string) => ReactDiffViewerStyles;
102
107
  export default _default;