react-diff-viewer-continued 4.3.0 → 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.
@@ -0,0 +1,311 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * First-party syntax highlighting for the diff viewer.
4
+ *
5
+ * The problem this solves: highlighting a diff line-by-line loses lexer state
6
+ * across line boundaries (a tag or string that wraps onto the next line is
7
+ * mis-tokenised), and highlighting that runs *after* the diff can't be merged
8
+ * with the word-level change marks on a changed line. So instead we highlight
9
+ * each whole side once — lexer state carries across every line — then express
10
+ * both the highlight tokens and the diff chunks as `[start, end)` intervals over
11
+ * the same line string and intersect them. Every resulting atom carries one
12
+ * colour (from the grammar) and one diff status (default / added / removed).
13
+ *
14
+ * Grammars are loaded lazily per language via `ensureLanguage`, tokenisation is
15
+ * synchronous (`refractor.highlight`), and colours are applied inline
16
+ * (`style={{ color }}`) so they never collide with host-page CSS.
17
+ */
18
+ import cn from "classnames";
19
+ import { refractor } from "refractor/core";
20
+ import { DiffType } from "./compute-lines.js";
21
+ import { resolveTokenColor } from "./highlight-theme.js";
22
+ /**
23
+ * Lazy grammar loaders. A static map (not `import(`refractor/${name}`)`) so a
24
+ * bundler can see every possible chunk and code-split them individually — only
25
+ * the grammar a consumer actually asks for is fetched. Covers refractor's
26
+ * "common" language set.
27
+ */
28
+ const LANGUAGE_LOADERS = {
29
+ arduino: () => import("refractor/arduino"),
30
+ bash: () => import("refractor/bash"),
31
+ basic: () => import("refractor/basic"),
32
+ c: () => import("refractor/c"),
33
+ clike: () => import("refractor/clike"),
34
+ cpp: () => import("refractor/cpp"),
35
+ csharp: () => import("refractor/csharp"),
36
+ css: () => import("refractor/css"),
37
+ diff: () => import("refractor/diff"),
38
+ go: () => import("refractor/go"),
39
+ ini: () => import("refractor/ini"),
40
+ java: () => import("refractor/java"),
41
+ javascript: () => import("refractor/javascript"),
42
+ json: () => import("refractor/json"),
43
+ kotlin: () => import("refractor/kotlin"),
44
+ less: () => import("refractor/less"),
45
+ lua: () => import("refractor/lua"),
46
+ makefile: () => import("refractor/makefile"),
47
+ markdown: () => import("refractor/markdown"),
48
+ markup: () => import("refractor/markup"),
49
+ "markup-templating": () => import("refractor/markup-templating"),
50
+ objectivec: () => import("refractor/objectivec"),
51
+ perl: () => import("refractor/perl"),
52
+ php: () => import("refractor/php"),
53
+ python: () => import("refractor/python"),
54
+ r: () => import("refractor/r"),
55
+ regex: () => import("refractor/regex"),
56
+ ruby: () => import("refractor/ruby"),
57
+ rust: () => import("refractor/rust"),
58
+ sass: () => import("refractor/sass"),
59
+ scss: () => import("refractor/scss"),
60
+ sql: () => import("refractor/sql"),
61
+ swift: () => import("refractor/swift"),
62
+ typescript: () => import("refractor/typescript"),
63
+ vbnet: () => import("refractor/vbnet"),
64
+ yaml: () => import("refractor/yaml"),
65
+ };
66
+ /**
67
+ * Friendly names → canonical loader keys. refractor also registers a grammar's
68
+ * own aliases on `register` (e.g. `markup` enables `html`/`xml`/`svg`, and
69
+ * `typescript` enables `ts`), but we still need to know *which* loader to run
70
+ * when a consumer passes the alias, so map the common ones here.
71
+ */
72
+ const LANGUAGE_ALIASES = {
73
+ html: "markup",
74
+ xml: "markup",
75
+ svg: "markup",
76
+ mathml: "markup",
77
+ ssml: "markup",
78
+ atom: "markup",
79
+ rss: "markup",
80
+ js: "javascript",
81
+ jsx: "javascript",
82
+ node: "javascript",
83
+ ts: "typescript",
84
+ tsx: "typescript",
85
+ py: "python",
86
+ rb: "ruby",
87
+ yml: "yaml",
88
+ md: "markdown",
89
+ sh: "bash",
90
+ shell: "bash",
91
+ zsh: "bash",
92
+ "c++": "cpp",
93
+ objc: "objectivec",
94
+ cs: "csharp",
95
+ dotnet: "csharp",
96
+ kt: "kotlin",
97
+ rs: "rust",
98
+ golang: "go",
99
+ };
100
+ /** Languages we've tried and failed to load — never retried. */
101
+ const negativeCache = new Set();
102
+ /** In-flight loads, deduped so concurrent requests share one import. */
103
+ const inflight = new Map();
104
+ /** Resolve a requested language name to its canonical (registerable) key. */
105
+ const canonicalName = (language) => {
106
+ const requested = language.toLowerCase();
107
+ return LANGUAGE_ALIASES[requested] ?? requested;
108
+ };
109
+ /**
110
+ * Ensures the grammar for `language` is registered, loading it lazily if needed.
111
+ * Resolves with the canonical language name to hand to `highlightSide`, or
112
+ * `null` if the language is unknown or failed to load (caller falls back to no
113
+ * highlighting). Safe to call repeatedly — registered/negative/in-flight are
114
+ * all cached.
115
+ */
116
+ export const ensureLanguage = async (language) => {
117
+ const canonical = canonicalName(language);
118
+ if (refractor.registered(canonical))
119
+ return canonical;
120
+ if (negativeCache.has(canonical))
121
+ return null;
122
+ const loader = LANGUAGE_LOADERS[canonical];
123
+ if (!loader) {
124
+ negativeCache.add(canonical);
125
+ return null;
126
+ }
127
+ let load = inflight.get(canonical);
128
+ if (!load) {
129
+ load = loader()
130
+ .then((mod) => {
131
+ refractor.register(mod.default);
132
+ })
133
+ .catch(() => {
134
+ negativeCache.add(canonical);
135
+ })
136
+ .finally(() => {
137
+ inflight.delete(canonical);
138
+ });
139
+ inflight.set(canonical, load);
140
+ }
141
+ await load;
142
+ return refractor.registered(canonical) ? canonical : null;
143
+ };
144
+ /**
145
+ * Walks the hast tree in document order, assigning each text leaf a character
146
+ * range and the colour of its innermost token class. Offsets are contiguous, so
147
+ * the leaves losslessly tile the input string.
148
+ */
149
+ const collectLeaves = (nodes, theme, color, offset, out) => {
150
+ let pos = offset;
151
+ for (const node of nodes) {
152
+ if (node.type === "text") {
153
+ const value = node.value ?? "";
154
+ if (value.length > 0) {
155
+ out.push({ start: pos, end: pos + value.length, color, text: value });
156
+ pos += value.length;
157
+ }
158
+ }
159
+ else if (node.children) {
160
+ const className = node.properties?.className;
161
+ const nextColor = Array.isArray(className)
162
+ ? resolveTokenColor(theme, className)
163
+ : color;
164
+ pos = collectLeaves(node.children, theme, nextColor, pos, out);
165
+ }
166
+ }
167
+ return pos;
168
+ };
169
+ /** Splits contiguous leaves into per-line token arrays, rebased to line-start. */
170
+ const tokensByLine = (leaves, lineCount) => {
171
+ const lines = Array.from({ length: lineCount }, () => []);
172
+ let line = 0;
173
+ let lineStart = 0;
174
+ for (const leaf of leaves) {
175
+ let segStart = leaf.start;
176
+ for (let i = 0; i < leaf.text.length; i++) {
177
+ if (leaf.text[i] === "\n") {
178
+ const segEnd = leaf.start + i;
179
+ if (segEnd > segStart && lines[line]) {
180
+ lines[line].push({
181
+ start: segStart - lineStart,
182
+ end: segEnd - lineStart,
183
+ color: leaf.color,
184
+ });
185
+ }
186
+ line += 1;
187
+ lineStart = leaf.start + i + 1;
188
+ segStart = lineStart;
189
+ }
190
+ }
191
+ if (leaf.end > segStart && lines[line]) {
192
+ lines[line].push({
193
+ start: segStart - lineStart,
194
+ end: leaf.end - lineStart,
195
+ color: leaf.color,
196
+ });
197
+ }
198
+ }
199
+ return lines;
200
+ };
201
+ /**
202
+ * Highlights an entire side and returns per-line token arrays (index `i` is the
203
+ * `i`-th line of `text`). Lexer state carries across lines because the whole
204
+ * string is tokenised in one pass. `language` must be a canonical name from
205
+ * `ensureLanguage`; returns `null` if tokenisation fails.
206
+ */
207
+ export const highlightSide = (text, language, theme) => {
208
+ if (text.length === 0)
209
+ return [];
210
+ let root;
211
+ try {
212
+ root = refractor.highlight(text, language);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ const leaves = [];
218
+ collectLeaves(root.children ?? [], theme, theme.default, 0, leaves);
219
+ const lineCount = text.split("\n").length;
220
+ return tokensByLine(leaves, lineCount);
221
+ };
222
+ /**
223
+ * Re-bases a line's tokens to a sub-range starting at `from` (used to drop the
224
+ * peeled leading-whitespace indent so body tokens align with the diff body).
225
+ */
226
+ export const sliceLineTokens = (tokens, from) => {
227
+ if (from <= 0)
228
+ return tokens;
229
+ const out = [];
230
+ for (const token of tokens) {
231
+ if (token.end <= from)
232
+ continue;
233
+ out.push({
234
+ start: Math.max(token.start, from) - from,
235
+ end: token.end - from,
236
+ color: token.color,
237
+ });
238
+ }
239
+ return out;
240
+ };
241
+ const renderAtom = (text, color, type, { styles, showHighlight }, key) => {
242
+ const style = color ? { color } : undefined;
243
+ if (type === DiffType.ADDED) {
244
+ return (_jsx("ins", { className: cn(styles.wordDiff, { [styles.wordAdded]: showHighlight }), style: style, children: text }, key));
245
+ }
246
+ if (type === DiffType.REMOVED) {
247
+ return (_jsx("del", { className: cn(styles.wordDiff, { [styles.wordRemoved]: showHighlight }), style: style, children: text }, key));
248
+ }
249
+ return (_jsx("span", { className: styles.wordDiff, style: style, children: text }, key));
250
+ };
251
+ /**
252
+ * Intersects a changed line's highlight tokens with its word-diff chunks. Both
253
+ * are contiguous partitions of the same body string, so a two-pointer walk over
254
+ * their union of boundaries yields atoms that each carry one colour and one diff
255
+ * status — preserving syntax colour *and* the `<ins>`/`<del>` word marks.
256
+ */
257
+ export const mergeHighlightWithDiff = (bodyText, bodyTokens, diffArray, options) => {
258
+ const ranges = [];
259
+ let pos = 0;
260
+ for (const diff of diffArray) {
261
+ const value = typeof diff.value === "string" ? diff.value : "";
262
+ if (value.length > 0) {
263
+ ranges.push({
264
+ start: pos,
265
+ end: pos + value.length,
266
+ type: diff.type ?? DiffType.DEFAULT,
267
+ });
268
+ pos += value.length;
269
+ }
270
+ }
271
+ const total = bodyText.length;
272
+ const out = [];
273
+ let ti = 0;
274
+ let ri = 0;
275
+ let cur = 0;
276
+ let key = 0;
277
+ while (cur < total) {
278
+ const token = bodyTokens[ti];
279
+ const range = ranges[ri];
280
+ const tokenEnd = token ? token.end : total;
281
+ const rangeEnd = range ? range.end : total;
282
+ const end = Math.min(tokenEnd, rangeEnd, total);
283
+ if (end <= cur) {
284
+ // Zero-width or stale interval — advance past it to guarantee progress.
285
+ if (tokenEnd <= cur)
286
+ ti += 1;
287
+ else if (rangeEnd <= cur)
288
+ ri += 1;
289
+ else
290
+ break;
291
+ continue;
292
+ }
293
+ out.push(renderAtom(bodyText.slice(cur, end), token?.color, range ? range.type : DiffType.DEFAULT, options, key++));
294
+ cur = end;
295
+ if (tokenEnd <= cur)
296
+ ti += 1;
297
+ if (rangeEnd <= cur)
298
+ ri += 1;
299
+ }
300
+ return out;
301
+ };
302
+ /**
303
+ * Renders a non-changed (context / fully-added / fully-removed) line as coloured
304
+ * spans, with no word-diff wrapping.
305
+ */
306
+ export const renderHighlightedPlain = (bodyText, bodyTokens) => {
307
+ if (bodyTokens.length === 0) {
308
+ return [_jsx("span", { children: bodyText }, 0)];
309
+ }
310
+ return bodyTokens.map((token, i) => (_jsx("span", { style: { color: token.color }, children: bodyText.slice(token.start, token.end) }, i)));
311
+ };
@@ -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) => ({ ...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] = { ...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.
@@ -152,6 +202,8 @@ class DiffViewer extends React.Component {
152
202
  styles;
153
203
  // Cache for on-demand word diff computation
154
204
  wordDiffCache = new Map();
205
+ // Ensures the highlightLanguage/renderContent precedence warning fires once.
206
+ highlightPrecedenceWarned = false;
155
207
  // Refs for measuring content column width and character width
156
208
  contentColumnRef = React.createRef();
157
209
  charMeasureRef = React.createRef();
@@ -187,6 +239,7 @@ class DiffViewer extends React.Component {
187
239
  charWidth: null,
188
240
  cumulativeOffsets: null,
189
241
  isScrolling: false,
242
+ highlightResult: null,
190
243
  };
191
244
  }
192
245
  /**
@@ -470,16 +523,56 @@ class DiffViewer extends React.Component {
470
523
  const added = type === DiffType.ADDED;
471
524
  const removed = type === DiffType.REMOVED;
472
525
  const changed = type === DiffType.CHANGED;
526
+ // Peel the leading whitespace into a separate indent column before any
527
+ // highlighting/word-diffing runs, so wrapped continuation lines hang-indent
528
+ // under the line's first character instead of the cell's left edge.
529
+ const { indent, rest } = splitLeadingWhitespace(value);
530
+ const hasWordDiff = Array.isArray(rest);
531
+ // First-party syntax highlighting: look up this line's whole-side tokens and
532
+ // rebase them past the peeled indent so they line up with the diff body.
533
+ const highlightMap = this.state.highlightResult
534
+ ? prefix === LineNumberPrefix.LEFT
535
+ ? this.state.highlightResult.left
536
+ : this.state.highlightResult.right
537
+ : null;
538
+ const sideLineNumber = lineNumber ?? additionalLineNumber ?? undefined;
539
+ const fullLineTokens = highlightMap && sideLineNumber != null ? highlightMap.get(sideLineNumber) : undefined;
540
+ const bodyTokens = fullLineTokens ? sliceLineTokens(fullLineTokens, indent.length) : undefined;
541
+ // Longest body we'll merge char-by-char before falling back to plain colour.
542
+ const MAX_HIGHLIGHT_MERGE_LENGTH = 500;
473
543
  let content;
474
- const hasWordDiff = Array.isArray(value);
475
- if (hasWordDiff) {
476
- content = this.renderWordDiff(value, this.props.renderContent);
544
+ if (bodyTokens) {
545
+ if (hasWordDiff) {
546
+ const bodyText = rest
547
+ .map((chunk) => (typeof chunk.value === "string" ? chunk.value : ""))
548
+ .join("");
549
+ content =
550
+ bodyText.length > MAX_HIGHLIGHT_MERGE_LENGTH
551
+ ? renderHighlightedPlain(bodyText, bodyTokens)
552
+ : mergeHighlightWithDiff(bodyText, bodyTokens, rest, {
553
+ styles: {
554
+ wordDiff: this.styles.wordDiff,
555
+ wordAdded: this.styles.wordAdded,
556
+ wordRemoved: this.styles.wordRemoved,
557
+ },
558
+ showHighlight: this.shouldHighlightWordDiff(),
559
+ });
560
+ }
561
+ else if (typeof rest === "string") {
562
+ content = renderHighlightedPlain(rest, bodyTokens);
563
+ }
564
+ else {
565
+ content = rest;
566
+ }
567
+ }
568
+ else if (hasWordDiff) {
569
+ content = this.renderWordDiff(rest, this.props.renderContent);
477
570
  }
478
- else if (this.props.renderContent && typeof value === "string") {
479
- content = this.props.renderContent(value);
571
+ else if (this.props.renderContent && typeof rest === "string") {
572
+ content = this.props.renderContent(rest);
480
573
  }
481
574
  else {
482
- content = value;
575
+ content = rest;
483
576
  }
484
577
  let ElementType = "div";
485
578
  if (added && !hasWordDiff) {
@@ -488,6 +581,9 @@ class DiffViewer extends React.Component {
488
581
  else if (removed && !hasWordDiff) {
489
582
  ElementType = "del";
490
583
  }
584
+ // A line is only "empty" (gets the empty-line background) when it has neither
585
+ // body content nor peeled indentation — a whitespace-only line is not empty.
586
+ const isEmpty = !content && !indent;
491
587
  return (_jsxs(_Fragment, { children: [!this.props.hideLineNumbers && (_jsx("td", { onClick: lineNumber && this.onLineNumberClickProxy(lineNumberTemplate), className: cn(this.styles.gutter, {
492
588
  [this.styles.emptyGutter]: !lineNumber,
493
589
  [this.styles.diffAdded]: added,
@@ -512,13 +608,13 @@ class DiffViewer extends React.Component {
512
608
  styles: this.styles,
513
609
  })
514
610
  : null, _jsx("td", { className: cn(this.styles.marker, {
515
- [this.styles.emptyLine]: !content,
611
+ [this.styles.emptyLine]: isEmpty,
516
612
  [this.styles.diffAdded]: added,
517
613
  [this.styles.diffRemoved]: removed,
518
614
  [this.styles.diffChanged]: changed,
519
615
  [this.styles.highlightedLine]: highlightLine,
520
616
  }), children: _jsxs("pre", { children: [added && "+", removed && "-"] }) }), _jsx("td", { ref: prefix === LineNumberPrefix.LEFT && !this.state.cumulativeOffsets ? this.contentColumnRef : undefined, className: cn(this.styles.content, {
521
- [this.styles.emptyLine]: !content,
617
+ [this.styles.emptyLine]: isEmpty,
522
618
  [this.styles.diffAdded]: added,
523
619
  [this.styles.diffRemoved]: removed,
524
620
  [this.styles.diffChanged]: changed,
@@ -542,7 +638,7 @@ class DiffViewer extends React.Component {
542
638
  ? "Added line"
543
639
  : removed && !hasWordDiff
544
640
  ? "Removed line"
545
- : undefined, children: _jsx(ElementType, { className: this.styles.contentText, children: content }) })] }));
641
+ : 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 })] }) })] }));
546
642
  };
547
643
  /**
548
644
  * Generates lines for split view.
@@ -644,6 +740,7 @@ class DiffViewer extends React.Component {
644
740
  ...prev,
645
741
  isLoading: false
646
742
  }));
743
+ this.updateHighlight();
647
744
  return;
648
745
  }
649
746
  // Defer word diff computation when using infinite loading with reasonable container height
@@ -668,6 +765,8 @@ class DiffViewer extends React.Component {
668
765
  computedDiffResult: this.state.computedDiffResult,
669
766
  isLoading: false,
670
767
  }), () => {
768
+ // Recompute syntax highlighting now that fresh line information exists.
769
+ this.updateHighlight();
671
770
  // Trigger offset recalculation after diff is computed and rendered
672
771
  // Use requestAnimationFrame to ensure DOM is ready for measurement
673
772
  if (this.props.infiniteLoading) {
@@ -675,6 +774,98 @@ class DiffViewer extends React.Component {
675
774
  }
676
775
  });
677
776
  };
777
+ /**
778
+ * Extracts the raw text of one side of a line for whole-side highlighting.
779
+ * Prefers `rawValue` (deferred word diff), then a plain string value, then the
780
+ * concatenation of word-diff chunk values.
781
+ */
782
+ lineToText = (info) => {
783
+ if (typeof info.rawValue === "string")
784
+ return info.rawValue;
785
+ if (typeof info.value === "string")
786
+ return info.value;
787
+ if (Array.isArray(info.value)) {
788
+ return info.value.map((chunk) => (typeof chunk.value === "string" ? chunk.value : "")).join("");
789
+ }
790
+ return "";
791
+ };
792
+ /**
793
+ * Reconstructs one side's full document from the computed line information and
794
+ * highlights it in a single pass, returning per-line tokens keyed by line
795
+ * number. Highlighting the whole side (rather than line-by-line) is what keeps
796
+ * lexer state correct across line boundaries.
797
+ */
798
+ buildSideTokens = (lineInformation, side, language, theme) => {
799
+ const entries = [];
800
+ for (const line of lineInformation) {
801
+ const info = line[side];
802
+ if (!info || info.lineNumber == null)
803
+ continue;
804
+ entries.push({ lineNumber: info.lineNumber, text: this.lineToText(info) });
805
+ }
806
+ entries.sort((a, b) => a.lineNumber - b.lineNumber);
807
+ const result = new Map();
808
+ if (entries.length === 0)
809
+ return result;
810
+ const perLine = highlightSide(entries.map((e) => e.text).join("\n"), language, theme);
811
+ if (!perLine)
812
+ return result;
813
+ for (let i = 0; i < entries.length; i++) {
814
+ result.set(entries[i].lineNumber, perLine[i] ?? []);
815
+ }
816
+ return result;
817
+ };
818
+ /**
819
+ * Resolves the active highlight theme (built-in light/dark, with any
820
+ * `highlightTheme` overrides merged on top).
821
+ */
822
+ resolveHighlightTheme = () => {
823
+ const base = this.props.useDarkTheme ? defaultDarkHighlightTheme : defaultLightHighlightTheme;
824
+ return this.props.highlightTheme ? { ...base, ...this.props.highlightTheme } : base;
825
+ };
826
+ /**
827
+ * Computes syntax-highlight tokens for both sides when `highlightLanguage` is
828
+ * set, storing them in state. Lazily loads the grammar, is memoised by
829
+ * (diff, language, theme, dark) so it is cheap to call repeatedly, and clears
830
+ * the result when highlighting is disabled or the language is unavailable.
831
+ */
832
+ updateHighlight = async () => {
833
+ const { highlightLanguage } = this.props;
834
+ if (!highlightLanguage) {
835
+ if (this.state.highlightResult)
836
+ this.setState({ highlightResult: null });
837
+ return;
838
+ }
839
+ const diffKey = this.getMemoisedKey();
840
+ const diffResult = this.state.computedDiffResult[diffKey];
841
+ // Diff not ready yet — memoisedCompute re-invokes updateHighlight when it is.
842
+ if (!diffResult)
843
+ return;
844
+ const dark = this.props.useDarkTheme ?? false;
845
+ const key = `${diffKey}::${highlightLanguage}::${dark}::${JSON.stringify(this.props.highlightTheme ?? null)}`;
846
+ if (this.state.highlightResult?.key === key)
847
+ return;
848
+ const canonical = await ensureLanguage(highlightLanguage);
849
+ // Bail if props changed while the grammar was loading.
850
+ if (this.props.highlightLanguage !== highlightLanguage || this.getMemoisedKey() !== diffKey)
851
+ return;
852
+ if (!canonical) {
853
+ if (this.state.highlightResult)
854
+ this.setState({ highlightResult: null });
855
+ return;
856
+ }
857
+ if (!this.highlightPrecedenceWarned &&
858
+ this.props.renderContent &&
859
+ typeof process !== "undefined" &&
860
+ process.env?.NODE_ENV !== "production") {
861
+ this.highlightPrecedenceWarned = true;
862
+ console.warn("[react-diff-viewer] `highlightLanguage` takes precedence over `renderContent`; `renderContent` is ignored for line content while highlighting is active.");
863
+ }
864
+ const theme = this.resolveHighlightTheme();
865
+ const left = this.buildSideTokens(diffResult.lineInformation, "left", canonical, theme);
866
+ const right = this.buildSideTokens(diffResult.lineInformation, "right", canonical, theme);
867
+ this.setState({ highlightResult: { key, left, right } });
868
+ };
678
869
  // Estimated row height based on lineHeight: 1.6em with 12px base font
679
870
  static ESTIMATED_ROW_HEIGHT = 19;
680
871
  /**
@@ -890,6 +1081,12 @@ class DiffViewer extends React.Component {
890
1081
  }));
891
1082
  this.memoisedCompute();
892
1083
  }
1084
+ else if (prevProps.highlightLanguage !== this.props.highlightLanguage ||
1085
+ prevProps.highlightTheme !== this.props.highlightTheme ||
1086
+ prevProps.useDarkTheme !== this.props.useDarkTheme) {
1087
+ // Highlighting inputs changed but the diff itself did not — just re-tokenise.
1088
+ this.updateHighlight();
1089
+ }
893
1090
  }
894
1091
  componentDidMount() {
895
1092
  this.setState((prev) => ({
@@ -1026,3 +1223,4 @@ export default DiffViewer;
1026
1223
  export { DiffMethod };
1027
1224
  export { default as computeStyles } from "./styles.js";
1028
1225
  export { defaultLightThemeVariables, defaultDarkThemeVariables, } from "./styles.js";
1226
+ export { defaultLightHighlightTheme, defaultDarkHighlightTheme, } from "./highlight-theme.js";