Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test scrolling helpers
run: node --experimental-default-type=module --test

- name: Install .NET workloads
run: dotnet workload install wasm-tools-net9

Expand Down
78 changes: 72 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ A Blazor component for displaying side-by-side text differences with character-l
- Character-level highlighting within changed lines
- Word-level soft highlight with character-level strong highlight for partial changes
- Adjacent character highlights merge into smooth pill shapes
- Collapse/expand unchanged sections
- Opt-in viewport virtualization for large documents
- Collapse/expand the comparison viewport
- Ignore case and whitespace options
- Custom header with diff statistics
- Custom CSS class and attribute support
Expand All @@ -24,7 +25,15 @@ A Blazor component for displaying side-by-side text differences with character-l

## Live Demo

[https://lzinga.github.io/BlazorTextDiff/](https://lzinga.github.io/BlazorTextDiff/)
Start with the [interactive playground](https://lzinga.github.io/BlazorTextDiff/): choose a small sample, adjust comparison options, or edit both source texts and apply them together. Component code follows the current options, with first-time setup available below the comparison.

The focused examples cover:

- [Character highlights](https://lzinga.github.io/BlazorTextDiff/character-highlight) — read edits within words, names, and values.
- [Async loading](https://lzinga.github.io/BlazorTextDiff/async) — fetch two pinned public README versions, with loading, error, and retry states.
- [Large files](https://lzinga.github.io/BlazorTextDiff/large-files) — generate JSON comparisons and explore virtualization, wrapping, viewport height, and deferred input updates.

Height limits keep the view compact; they do not remove unchanged lines. Virtualization limits rendered rows, not the full-document diff calculation. Each example includes optional explanations and selectable code.

## Installation

Expand All @@ -40,7 +49,7 @@ Add the stylesheet to your `index.html` or `_Host.cshtml`:
<link href="_content/BlazorTextDiff/css/BlazorDiff.css" rel="stylesheet" />
```

No JavaScript or service registration is required.
No manual script tags or service registration are required. Virtualized and nonwrapping modes automatically import the library's scrolling helper.

## Usage

Expand Down Expand Up @@ -75,15 +84,72 @@ No JavaScript or service registration is required.
|---|---|---|---|
| `OldText` | `string?` | `null` | Original text (left pane) |
| `NewText` | `string?` | `null` | Modified text (right pane) |
| `CollapseContent` | `bool` | `false` | Collapse unchanged sections |
| `MaxHeight` | `int` | `300` | Max height (px) when collapsed |
| `DeferDiff` | `bool` | `false` | Keep the last comparison while loading inputs; set to `false` to compare the latest text and options |
| `Virtualize` | `bool` | `false` | Render only visible, fixed-height rows with synchronized scrolling on both axes |
| `WrapLines` | `bool` | `true` | Wrap long lines in nonvirtualized mode; virtualized mode always disables wrapping |
| `CollapseContent` | `bool` | `false` | Collapse the view; ignored in virtualized mode |
| `MaxHeight` | `int` | `300` | Collapsed maximum height, or the fixed viewport height in virtualized mode (px; must be positive when virtualizing) |
| `IgnoreCase` | `bool` | `false` | Ignore case differences |
| `IgnoreWhiteSpace` | `bool` | `false` | Ignore whitespace differences |
| `Header` | `RenderFragment<DiffStats>?` | `null` | Custom header template |
| `Class` | `string?` | `null` | Additional CSS class(es) |

Unmatched HTML attributes (`style`, `id`, `data-*`, etc.) are passed through to the root element.

### Loading Text in Stages

Use `DeferDiff` to avoid comparing intermediate inputs when loading the two sides separately:

```razor
<TextDiff OldText="@oldText"
NewText="@newText"
DeferDiff="@isLoading" />
```

Set `isLoading` to `true` before loading either side, then set it to `false` once the inputs are ready. While deferred, the component keeps the previous comparison visible (or renders no panes if it has not compared yet). Releasing deferral compares the latest texts and ignore options.

Deferral is opt-in: an empty or `null` side is still a valid input for showing additions or deletions. Clearing both sides removes the previous comparison once deferral is released.

### Performance

The component reuses its last diff when the text values and ignore options have not changed. Presentation changes, including wrapping and virtualization, do not recompute the diff. `null` and empty strings are treated as equivalent inputs.

Within each word, adjacent characters with the same change type share a highlight span. Consecutive changed whitespace is grouped too. By default all lines are rendered; enable `Virtualize` to limit rendering to the viewport.

### Nonwrapping Comparisons

Actual newline characters are preserved in every mode. `WrapLines` controls only whether a long source line wraps visually.
To keep each source line on one row without enabling virtualization:

```razor
<TextDiff OldText="@oldText"
NewText="@newText"
WrapLines="false"
CollapseContent="true"
MaxHeight="500" />
```

This still renders every row, with synchronized horizontal and vertical scrolling. While collapsed, `MaxHeight` limits each pane so its horizontal scrollbar remains accessible. Expanding removes that height limit. Wrapping remains enabled by default.

### Large Documents

```razor
<TextDiff OldText="@oldText"
NewText="@newText"
Virtualize="true"
MaxHeight="500" />
```

Virtualized mode uses Blazor's built-in `Virtualize` component in each pane. Both panes use the aligned rows from the same diff model, including empty placeholders for additions and deletions, and their horizontal and vertical scroll positions are synchronized. Each pane clamps to its own scrollable range without pulling the other pane back. Only the visible rows plus a small scrolling buffer are rendered.

The panes have a fixed viewport height controlled by `MaxHeight`. `CollapseContent` is ignored and the expand button is hidden in this mode. Lines use a fixed 30 px height and do not wrap, regardless of `WrapLines`; either pane can be focused for keyboard scrolling. The panes stay side by side even on narrow screens. Keep the fixed row geometry intact when applying custom styles so the virtualizer can calculate accurate scroll positions.

Virtualization reduces rendering and DOM costs, not the initial full-document diff calculation or the memory needed for the diff model. A single enormous line still needs to be compared and rendered when visible.

Offscreen rows are not present in the DOM, so browser find, text selection, and printing cannot include them. Turn virtualization off and expand the view when you need the full document in the page. Existing wrapped rendering remains the default.

The [large-file demo](https://lzinga.github.io/BlazorTextDiff/large-files) includes generated JSON comparisons, document-size and viewport controls, and virtualization and wrapping toggles. It also demonstrates `DeferDiff` while the inputs are generated in separate stages.

## How Character Highlighting Works

The component uses three levels of visual hierarchy:
Expand All @@ -98,7 +164,7 @@ For example, `Programing` → `Programming`:

When a word is entirely changed (e.g. `cat` → `dog`), it skips the word wrapper and uses the character-level class directly.

Adjacent character highlights automatically merge into a single pill shape — rounded corners only appear on the first and last character in a run.
Within a word, adjacent changed characters with the same change type render as a single highlighted run, preserving the pill shape without a separate span for every character.

## Customization

Expand Down
9 changes: 7 additions & 2 deletions src/BlazorTextDiff.Web/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<PageTitle>Page not found · BlazorTextDiff</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
<header class="page-header">
<p class="eyebrow">BlazorTextDiff demo</p>
<h1 tabindex="-1">Page not found</h1>
<p>This address doesn't match a demo page.</p>
</header>
<a href="" class="btn btn-primary">Back to the playground</a>
</LayoutView>
</NotFound>
</Router>
181 changes: 129 additions & 52 deletions src/BlazorTextDiff.Web/Pages/Async.razor
Original file line number Diff line number Diff line change
@@ -1,58 +1,82 @@
@page "/async"
@page "/async"
@inject HttpClient Http

<PageTitle>Async Loading - BlazorTextDiff</PageTitle>
<PageTitle>Async loading · BlazorTextDiff</PageTitle>

<h1>Async Content Loading</h1>
<p>Load and compare content from remote sources. This example fetches two versions of a README from GitHub.</p>
<header class="page-header">
<p class="eyebrow">Real content, loaded on demand</p>
<h1 tabindex="-1">Async loading</h1>
<p>Fetch two versions of a public README and compare them once both are ready. Reload to see the loading state again.</p>
</header>

<div class="mb-3">
<button type="button" class="btn btn-sm @(collapseContent ? "btn-primary" : "btn-outline-primary")" @onclick="() => collapseContent = !collapseContent" disabled="@(isLoading || hasError)">
@(collapseContent ? "▶ Collapse Unchanged" : "▤ Show All Lines")
</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" @onclick="LoadContentAsync" disabled="@isLoading">
↻ Reload
<div class="demo-controls">
<button id="reload-content" type="button" class="btn btn-primary" @onclick="LoadContentAsync" disabled="@isLoading">
@(isLoading ? "Loading both versions…" : hasError ? "Retry loading" : "Reload comparison")
</button>
<div class="check-control">
<input id="async-limit-height" type="checkbox" @bind="collapseContent" disabled="@(isLoading || hasError)" aria-describedby="async-height-help" />
<label for="async-limit-height">Limit comparison height</label>
</div>
</div>
<p id="async-height-help" class="control-hint">The height limit keeps the view compact. <strong>Show more</strong> expands it; no unchanged lines are removed.</p>

@if (isLoading)
{
<div class="diff-loading-overlay">
<div class="diff-loading-content">
<div class="loading-spinner loading-spinner-lg loading-pulse mb-3" role="status" aria-label="Loading content"></div>
<h5>Loading Content...</h5>
<small>Fetching README files from two GitHub commits.</small>
<section aria-labelledby="readme-heading">
<h2 id="readme-heading">README.md</h2>
@if (isLoading)
{
<div class="status-panel" role="status">
<span class="loading-spinner loading-spinner-lg" aria-hidden="true"></span>
<div>
<strong>Fetching both versions…</strong>
<p>Two requests run together. They share a 30-second timeout.</p>
</div>
</div>
</div>
}
else if (hasError)
{
<div class="alert alert-danger" role="alert">
<strong>Failed to load content.</strong> Check your network connection and try again.
<button type="button" class="btn btn-sm btn-outline-danger ms-2" @onclick="LoadContentAsync">Retry</button>
</div>
}
else
{
<TextDiff OldText="@leftContent" NewText="@rightContent" CollapseContent="@collapseContent">
<Header>
<div style="padding: 10px 12px; background-color: #f8f9fa; border-bottom: 1px solid #dee2e6;">
<strong>README.md</strong> —
<a href="https://github.com/lzinga/TTTWeightedTraitorSelection" target="_blank" rel="noopener noreferrer">TTTWeightedTraitorSelection</a>
<small class="text-muted ms-2">
<code>fe20c3e</code> → <code>c763193</code>
</small>
<div class="diff-stats-container mt-1">
<span class="diff-stats-badge warning">@context.LineModificationCount modified</span>
<span class="diff-stats-badge danger">@context.LineDeletionCount deleted</span>
<span class="diff-stats-badge success">@context.LineAdditionCount added</span>
</div>
}
else if (hasError)
{
<div class="status-panel status-error" role="alert">
<div>
<strong>We couldn't load both versions.</strong>
<p>Check your connection, then choose <strong>Retry loading</strong>. GitHub may also be temporarily unavailable.</p>
<p class="helper-text">No partial comparison is shown if either request fails.</p>
</div>
</Header>
</TextDiff>
}
</div>
}
else
{
<TextDiff OldText="@leftContent" NewText="@rightContent" CollapseContent="@collapseContent"
MaxHeight="400" Class="demo-diff" id="async-diff">
<Header>
<ComparisonHeader Stats="@context" OriginalLabel="Original · fe20c3e" ModifiedLabel="Modified · c763193" />
</Header>
</TextDiff>
}
</section>

<details id="async-sources" class="demo-disclosure">
<summary>About these source files <span class="summary-hint">Two pinned commits</span></summary>
<div class="disclosure-body">
<p class="helper-text">Both files are from <a href="https://github.com/lzinga/TTTWeightedTraitorSelection">TTTWeightedTraitorSelection</a>. The commit IDs are pinned so reloading compares the same versions.</p>
<dl class="note-list source-list">
<dt>Original</dt>
<dd><a href="@OriginalUrl">README.md at fe20c3e</a></dd>
<dt>Modified</dt>
<dd><a href="@ModifiedUrl">README.md at c763193</a></dd>
</dl>
<p class="helper-text">This page needs network access to <code>raw.githubusercontent.com</code>. The files are fetched in your browser, not bundled with the demo.</p>
</div>
</details>

<CodeExample Id="async-code" Title="Load both texts before comparing" Code="@Usage">
<p class="helper-text">Start both requests before awaiting <code>Task.WhenAll</code>. Replace the inputs only after both succeed, and keep loading, error, and retry states in the page.</p>
</CodeExample>

<p class="next-step">Next: <a href="large-files">explore large-file rendering and staged input generation</a>.</p>

@code {
private const string OriginalUrl = "https://raw.githubusercontent.com/lzinga/TTTWeightedTraitorSelection/fe20c3e645aaa20e40cecc615037d51a34f9cb4a/README.md";
private const string ModifiedUrl = "https://raw.githubusercontent.com/lzinga/TTTWeightedTraitorSelection/c763193e8a5bddfbec097c7b96ea0f875eedb01b/README.md";

private string leftContent = string.Empty;
private string rightContent = string.Empty;
private bool collapseContent = true;
Expand All @@ -74,19 +98,15 @@ else
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

var leftTask = Http.GetStringAsync(
"https://raw.githubusercontent.com/lzinga/TTTWeightedTraitorSelection/fe20c3e645aaa20e40cecc615037d51a34f9cb4a/README.md",
cts.Token);
var rightTask = Http.GetStringAsync(
"https://raw.githubusercontent.com/lzinga/TTTWeightedTraitorSelection/c763193e8a5bddfbec097c7b96ea0f875eedb01b/README.md",
cts.Token);
var leftTask = Http.GetStringAsync(OriginalUrl, cts.Token);
var rightTask = Http.GetStringAsync(ModifiedUrl, cts.Token);

await Task.WhenAll(leftTask, rightTask);

leftContent = await leftTask;
rightContent = await rightTask;
}
catch
catch (Exception error) when (error is HttpRequestException or OperationCanceledException)
{
hasError = true;
leftContent = string.Empty;
Expand All @@ -98,4 +118,61 @@ else
StateHasChanged();
}
}
}

private string Usage => $@"@inject HttpClient Http

<button @onclick=""LoadContentAsync"" disabled=""@isLoading"">
@(hasError ? ""Retry loading"" : ""Reload comparison"")
</button>

@if (isLoading)
{{
<p role=""status"">Loading both versions…</p>
}}
else if (hasError)
{{
<p role=""alert"">Could not load both versions. Please try again.</p>
}}
else
{{
<TextDiff OldText=""@leftContent"" NewText=""@rightContent""
CollapseContent=""{collapseContent.ToString().ToLowerInvariant()}"" MaxHeight=""400"" />
}}

@code {{
private string leftContent = string.Empty;
private string rightContent = string.Empty;
private bool isLoading = true;
private bool hasError;

protected override Task OnInitializedAsync() => LoadContentAsync();

private async Task LoadContentAsync()
{{
isLoading = true;
hasError = false;
StateHasChanged();
try
{{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var leftTask = Http.GetStringAsync(
""{OriginalUrl}"", cts.Token);
var rightTask = Http.GetStringAsync(
""{ModifiedUrl}"", cts.Token);
await Task.WhenAll(leftTask, rightTask);
leftContent = await leftTask;
rightContent = await rightTask;
}}
catch (Exception error) when (error is HttpRequestException or OperationCanceledException)
{{
hasError = true;
leftContent = rightContent = string.Empty;
}}
finally
{{
isLoading = false;
StateHasChanged();
}}
}}
}}";
}
Loading
Loading