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.
- package/.github/workflows/pages.yml +36 -0
- package/.github/workflows/publish.yml +2 -10
- package/CHANGELOG.md +38 -0
- package/README.md +5 -3
- package/lib/cjs/src/expand.d.ts +1 -1
- package/lib/cjs/src/fold.d.ts +1 -1
- package/lib/cjs/src/highlight-theme.d.ts +29 -0
- package/lib/cjs/src/highlight-theme.js +99 -0
- package/lib/cjs/src/highlight.d.ts +69 -0
- package/lib/cjs/src/highlight.js +315 -0
- package/lib/cjs/src/index.d.ts +54 -3
- package/lib/cjs/src/index.js +211 -10
- package/lib/cjs/src/styles.d.ts +5 -0
- package/lib/cjs/src/styles.js +137 -56
- package/lib/cjs/src/workerBundle.d.ts +1 -1
- package/lib/cjs/src/workerBundle.js +1 -1
- package/lib/esm/src/highlight-theme.js +99 -0
- package/lib/esm/src/highlight.js +311 -0
- package/lib/esm/src/index.js +208 -9
- package/lib/esm/src/styles.js +137 -56
- package/lib/esm/src/workerBundle.js +1 -1
- package/package.json +16 -12
- package/pnpm-workspace.yaml +7 -0
- package/publish-examples.sh +0 -10
|
@@ -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
|
+
};
|
package/lib/esm/src/index.js
CHANGED
|
@@ -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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
479
|
-
content = this.props.renderContent(
|
|
571
|
+
else if (this.props.renderContent && typeof rest === "string") {
|
|
572
|
+
content = this.props.renderContent(rest);
|
|
480
573
|
}
|
|
481
574
|
else {
|
|
482
|
-
content =
|
|
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]:
|
|
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]:
|
|
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:
|
|
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) => ({
|
|
@@ -1025,3 +1222,5 @@ class DiffViewer extends React.Component {
|
|
|
1025
1222
|
export default DiffViewer;
|
|
1026
1223
|
export { DiffMethod };
|
|
1027
1224
|
export { default as computeStyles } from "./styles.js";
|
|
1225
|
+
export { defaultLightThemeVariables, defaultDarkThemeVariables, } from "./styles.js";
|
|
1226
|
+
export { defaultLightHighlightTheme, defaultDarkHighlightTheme, } from "./highlight-theme.js";
|