From bd1976fa24acba8058d7be1f7458e44e06c1a62c Mon Sep 17 00:00:00 2001 From: lzinga Date: Thu, 10 Sep 2026 19:08:04 -0700 Subject: [PATCH 1/2] Fix diff loading, performance, and demos Fixes #12 Fixes #15 Fixes #16 --- .github/workflows/ci.yml | 8 + README.md | 78 +- src/BlazorTextDiff.Web/App.razor | 9 +- src/BlazorTextDiff.Web/Pages/Async.razor | 181 ++-- .../Pages/CharacterHighlight.razor | 138 ++- src/BlazorTextDiff.Web/Pages/Index.razor | 281 ++++-- src/BlazorTextDiff.Web/Pages/LargeFiles.razor | 204 +++++ .../Shared/CodeExample.razor | 15 + .../Shared/ComparisonHeader.razor | 31 + .../Shared/MainLayout.razor | 14 +- src/BlazorTextDiff.Web/Shared/NavMenu.razor | 57 +- src/BlazorTextDiff.Web/wwwroot/css/site.css | 822 ++++++++++++++---- src/BlazorTextDiff.Web/wwwroot/index.html | 32 +- src/BlazorTextDiff/ScrollableTextDiff.razor | 94 ++ src/BlazorTextDiff/TextDiff.razor | 100 ++- src/BlazorTextDiff/TextDiffLine.razor | 87 +- src/BlazorTextDiff/TextDiffPane.razor | 62 +- src/BlazorTextDiff/wwwroot/css/BlazorDiff.css | 65 ++ .../wwwroot/css/BlazorDiff.min.css | 3 +- .../wwwroot/js/virtualizedDiff.js | 38 + .../BlazorTextDiff.Tests/TextDiffLineTests.cs | 93 ++ tests/BlazorTextDiff.Tests/TextDiffTests.cs | 306 +++++++ .../TextDiffVirtualizationTests.cs | 346 ++++++++ tests/virtualizedDiff.test.mjs | 146 ++++ 24 files changed, 2723 insertions(+), 487 deletions(-) create mode 100644 src/BlazorTextDiff.Web/Pages/LargeFiles.razor create mode 100644 src/BlazorTextDiff.Web/Shared/CodeExample.razor create mode 100644 src/BlazorTextDiff.Web/Shared/ComparisonHeader.razor create mode 100644 src/BlazorTextDiff/ScrollableTextDiff.razor create mode 100644 src/BlazorTextDiff/wwwroot/js/virtualizedDiff.js create mode 100644 tests/BlazorTextDiff.Tests/TextDiffVirtualizationTests.cs create mode 100644 tests/virtualizedDiff.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8141431..d749158 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index ae22263..dedcff4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -40,7 +49,7 @@ Add the stylesheet to your `index.html` or `_Host.cshtml`: ``` -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 @@ -75,8 +84,11 @@ 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?` | `null` | Custom header template | @@ -84,6 +96,60 @@ No JavaScript or service registration is required. 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 + +``` + +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 + +``` + +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 + +``` + +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: @@ -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 diff --git a/src/BlazorTextDiff.Web/App.razor b/src/BlazorTextDiff.Web/App.razor index b57f803..238b5f8 100644 --- a/src/BlazorTextDiff.Web/App.razor +++ b/src/BlazorTextDiff.Web/App.razor @@ -4,9 +4,14 @@ - Not found + Page not found · BlazorTextDiff -

Sorry, there's nothing at this address.

+ + Back to the playground
diff --git a/src/BlazorTextDiff.Web/Pages/Async.razor b/src/BlazorTextDiff.Web/Pages/Async.razor index 3c21b08..650a61a 100644 --- a/src/BlazorTextDiff.Web/Pages/Async.razor +++ b/src/BlazorTextDiff.Web/Pages/Async.razor @@ -1,58 +1,82 @@ -@page "/async" +@page "/async" @inject HttpClient Http -Async Loading - BlazorTextDiff +Async loading · BlazorTextDiff -

Async Content Loading

-

Load and compare content from remote sources. This example fetches two versions of a README from GitHub.

+ -
- - +
+ + +
+

The height limit keeps the view compact. Show more expands it; no unchanged lines are removed.

-@if (isLoading) -{ -
-
-
-
Loading Content...
- Fetching README files from two GitHub commits. +
+

README.md

+ @if (isLoading) + { +
+ +
+ Fetching both versions… +

Two requests run together. They share a 30-second timeout.

+
-
-} -else if (hasError) -{ - -} -else -{ - -
-
- README.md — - TTTWeightedTraitorSelection - - fe20c3e → c763193 - -
- @context.LineModificationCount modified - @context.LineDeletionCount deleted - @context.LineAdditionCount added -
+ } + else if (hasError) + { +
-
-} +
+ } + else + { + +
+ +
+
+ } + + +
+ About these source files Two pinned commits +
+

Both files are from TTTWeightedTraitorSelection. The commit IDs are pinned so reloading compares the same versions.

+
+
Original
+
README.md at fe20c3e
+
Modified
+
README.md at c763193
+
+

This page needs network access to raw.githubusercontent.com. The files are fetched in your browser, not bundled with the demo.

+
+
+ + +

Start both requests before awaiting Task.WhenAll. Replace the inputs only after both succeed, and keep loading, error, and retry states in the page.

+
+ +

Next: explore large-file rendering and staged input generation.

@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; @@ -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; @@ -98,4 +118,61 @@ else StateHasChanged(); } } -} \ No newline at end of file + + private string Usage => $@"@inject HttpClient Http + + + +@if (isLoading) +{{ +

Loading both versions…

+}} +else if (hasError) +{{ +

Could not load both versions. Please try again.

+}} +else +{{ + +}} + +@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(); + }} + }} +}}"; +} diff --git a/src/BlazorTextDiff.Web/Pages/CharacterHighlight.razor b/src/BlazorTextDiff.Web/Pages/CharacterHighlight.razor index 4692eaa..9fe4326 100644 --- a/src/BlazorTextDiff.Web/Pages/CharacterHighlight.razor +++ b/src/BlazorTextDiff.Web/Pages/CharacterHighlight.razor @@ -1,94 +1,72 @@ @page "/character-highlight" -Character Highlighting - BlazorTextDiff +Character highlights · BlazorTextDiff -

Character-Level Highlighting

-

Individual changed characters within a line are highlighted with a distinct color, just like Git's inline diff.

+ -

Typo Corrections

- -
-
-
- @context.LineModificationCount modified - @context.WordModificationCount words -
-
-
-
+
+

Small edits within a word

+

Look for the added d in completed and the extra p removed from repport. The last line stays unchanged.

+ +
+ +
+
+
-

Code Refactoring

-

Renamed variables, changed values, and updated method signatures — only the exact characters that differ are indicated.

- -
-
-
- @context.LineModificationCount modified - @context.LineDeletionCount deleted - @context.LineAdditionCount added -
-
-
-
+
+

Names and values in code

+

Follow a renamed variable, a new value, and an updated filename. Stronger highlights isolate the edits inside each changed line.

+ +
+ +
+
+
-@code { - private const string typoOld = @"The quik brown fox jumpd over the layz dog. -Programing is the art of writting code. -Recieve the mesage and procede with causion. -Definately check for boundry conditions."; - - private const string typoNew = @"The quick brown fox jumped over the lazy dog. -Programming is the art of writing code. -Receive the message and proceed with caution. -Definitely check for boundary conditions."; +
+ How to read the highlights Line → word → character +
+
+
Start with the row
+
Amber marks a modified line, red a removed line, and green an added line. Unchanged lines stay uncolored.
+
Look inside the word
+
A partially changed word gets a soft highlight for context. A stronger highlight marks the characters that differ.
+
Read a run, not a box per character
+
Adjacent changed characters within a word share a highlight. A fully changed word uses the strong highlight without an extra word wrapper.
+
+

The header counts lines, not highlighted characters. One modified line can contain several edits.

+
+
- private const string codeOld = @"public class UserService -{ - private readonly ILogger _log; - private readonly int _maxRetries = 3; + +

No extra flag is needed. Include the library stylesheet as shown in the playground setup. Colors and highlight shapes can be customized with CSS properties.

+
- public UserService(ILogger log) - { - _log = log; - } +

Next: load two versions from a remote source, or try your own texts in the playground.

- public async Task GetUser(int userId) - { - _log.Info($""Fetching user {userId}""); - var response = await _httpClient.GetAsync($""/api/users/{userId}""); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsAsync(); - } - - public void DeleteUser(int userId) - { - _log.Warn($""Deleting user {userId}""); - _httpClient.DeleteAsync($""/api/users/{userId}""); - } -}"; +@code { + private const string typoOld = @"Export complete. +Save the repport. +Keep a local copy."; - private const string codeNew = @"public class UserService -{ - private readonly ILogger _logger; - private readonly int _maxRetries = 5; + private const string typoNew = @"Export completed. +Save the report. +Keep a local copy."; - public UserService(ILogger logger) - { - _logger = logger; - } + private const string codeOld = @"var retryLimit = 3; +var fileName = ""draft.csv""; +var timeout = TimeSpan.FromSeconds(10); +await ExportAsync(fileName, retryLimit, timeout);"; - public async Task GetUserAsync(int userId) - { - _logger.LogInformation($""Fetching user {userId}""); - var response = await _httpClient.GetAsync($""/api/v2/users/{userId}""); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(); - } + private const string codeNew = @"var maxRetries = 5; +var fileName = ""final.json""; +var timeout = TimeSpan.FromSeconds(30); +await ExportAsync(fileName, maxRetries, timeout);"; - public async Task DeleteUserAsync(int userId) - { - _logger.LogWarning($""Deleting user {userId}""); - await _httpClient.DeleteAsync($""/api/v2/users/{userId}""); - } -}"; + private const string Usage = @""; } diff --git a/src/BlazorTextDiff.Web/Pages/Index.razor b/src/BlazorTextDiff.Web/Pages/Index.razor index 90ba068..b64ce0d 100644 --- a/src/BlazorTextDiff.Web/Pages/Index.razor +++ b/src/BlazorTextDiff.Web/Pages/Index.razor @@ -1,75 +1,236 @@ -@page "/" - -BlazorTextDiff Demo - -

BlazorTextDiff

-

A Blazor component for side-by-side text comparison with character-level highlighting, powered by DiffPlex.

- -
- - - +@page "/" + +Playground · BlazorTextDiff + + + +
+
+ + +
+
+ + +
+
+ + +
+

+ @if (HasPendingEdits) + { + You have unapplied edits. Choose Compare texts in the source editors to apply them. Choosing a sample will replace them. + } + else if (HasCustomTexts) + { + Your edited texts are shown. Choose a sample to start over. + } + else + { + @SelectedSample.Description + } +

+ +@if (string.IsNullOrEmpty(oldText) && string.IsNullOrEmpty(newText)) +{ +
+
+ Both texts are empty. +

Add text on either side in the source editors, then choose Compare texts.

+
+
+} +else +{ + +
+ +
+
+} +

Row colors mark changed lines. Stronger highlights pinpoint the changed characters. Totals include the whole comparison, not just the visible area.

+ +
+ Edit source texts Apply both together +
+

Edits stay in this browser page and are compared only when you choose Compare texts. Choosing a sample replaces both editors.

+
+
+ + +
+
+ + +
+
+
+ + + @(HasPendingEdits ? "Unapplied edits — the comparison still shows the previous texts." : "The comparison is up to date.") +
+

An empty original shows additions; an empty modified text shows removals. Text edits are not saved when you leave this page.

+
+
- -
-
-
- @context.LineModificationCount modified - @context.LineDeletionCount deleted - @context.LineAdditionCount added +
+ Display options Wrapping & height +
+
+
+ + +
+
+ + +
+
+ +
-
-
+

Wrapping changes only how long lines fit on screen; source line breaks stay intact. A height limit adds Show more / Show less controls. It does not hide unchanged lines.

+

For large documents and synchronized scrolling, try the large-file demo.

+
+ -

Without Header

- +
+

Use it in your app

+ +

Set oldText and newText to your strings. The demo adds an optional Header for the line totals above.

+
+
+ First-time setup Package, styles & namespace +
+
    +
  1. + Install the package in your Blazor app. +
    dotnet add package BlazorTextDiff
    +
  2. +
  3. + Add the stylesheet to your host page, such as index.html or _Host.cshtml. +
    @StylesheetExample
    +
  4. +
  5. + Add the namespace to _Imports.razor, then use TextDiff in a Razor page. +
    @ImportExample
    +
  6. +
+

No manual script tags or service registration are needed. See the full parameter reference for more options.

+
+
+
+ + @code { - private bool collapseContent = true; + private sealed record Sample(string Id, string Name, string Description, string Original, string Modified); + + private static readonly Sample[] Samples = + { + new("settings", "Settings JSON", "Changed values, a removed setting, and a new setting — all in a few lines.", + @"{ + ""theme"": ""light"", + ""pageSize"": 20, + ""legacyMenu"": true, + ""showTips"": true, + ""cacheMinutes"": 5 +}", + @"{ + ""theme"": ""dark"", + ""pageSize"": 50, + ""showTips"": true, + ""autoSave"": true, + ""cacheMinutes"": 5 +}"), + new("code", "Small code change", "Follow an export setting from its value to the method call.", + @"var fileName = ""report.txt""; +var maxAttempts = 3; +var includeHeader = false; + +await ExportAsync(fileName, maxAttempts, includeHeader);", + @"var fileName = ""report.csv""; +var maxAttempts = 5; +var includeHeader = true; +var delimiter = "",""; + +await ExportAsync(fileName, maxAttempts, includeHeader, delimiter);"), + new("formatting", "Case & spacing", "Try both ignore options: only casing and leading spaces differ.", + @"LogLevel = info +Region = west +RetryLimit = 3", + @"loglevel = INFO + Region = west +RetryLimit = 3") + }; + + private string sampleId = Samples[0].Id; + private string oldText = Samples[0].Original; + private string newText = Samples[0].Modified; + private string draftOldText = Samples[0].Original; + private string draftNewText = Samples[0].Modified; private bool ignoreCase; private bool ignoreWhiteSpace; + private bool wrapLines = true; + private bool collapseContent; + private int maxHeight = 360; - private const string oldText = @"Mr. and Mrs. Fursley, of number six, Sunset Drive, were proud to say that they were almost perfectly normal. They were the last people you'd expect to be involved in anything weird or mysterious, because they just didn't hold with such nonsense. -Mr. Fursley was the director of a firm called Brunings, which made hatchets. He was a big, well fed man with no neck, although he did have a very large mustache. Mrs. Fursley was petite and blonde and had nearly twice the usual amount of neck, which came in very useful as she spent so much of her time bending over garden fences, spying on the neighbors. The Fursley's had a small son called Fudley and in their opinion there was no finer boy anywhere. -The Dursleys had everything they wanted, but they also had a secret. -Their greatest fear was that somebody would discover it. + private Sample SelectedSample => Samples.First(sample => sample.Id == sampleId); + private bool HasPendingEdits => draftOldText != oldText || draftNewText != newText; + private bool HasCustomTexts => oldText != SelectedSample.Original || newText != SelectedSample.Modified; + private bool CanResetSample => HasPendingEdits || HasCustomTexts; -function example() { - return 'tabs'; -} + private void LoadSample() + { + oldText = draftOldText = SelectedSample.Original; + newText = draftNewText = SelectedSample.Modified; + } - - - -"; + private void ApplyTexts() + { + oldText = draftOldText; + newText = draftNewText; + } - private const string newText = @"Mr. and Mrs. Dursley, of number four, Privet Drive, were proud to say that they were perfectly normal, thank you very much. They were the last people you'd expect to be involved in anything strange or mysterious, because they just didn't hold with such nonsense. -Mr. Dursley was the director of a firm called Grunnings, which made drills. He was a big, beefy man with hardly any neck, although he did have a very large mustache. Mrs. Dursley was thin and blonde and had nearly twice the usual amount of neck, which came in very useful as she spent so much of her time craning over garden fences, spying on the neighbors. The Dursleys had a small son called Dudley and in their opinion there was no finer boy anywhere. -The Dursleys had everything they wanted, but they also had a secret. -Their greatest fear was that somebody would discover it. + private string Usage => $@""; -function example() { - return 'spaces'; + private const string StylesheetExample = @""; + private const string ImportExample = "@using BlazorTextDiff"; } - - - - -"; -} \ No newline at end of file diff --git a/src/BlazorTextDiff.Web/Pages/LargeFiles.razor b/src/BlazorTextDiff.Web/Pages/LargeFiles.razor new file mode 100644 index 0000000..eca9280 --- /dev/null +++ b/src/BlazorTextDiff.Web/Pages/LargeFiles.razor @@ -0,0 +1,204 @@ +@page "/large-files" +@using System.Text + +Large files · BlazorTextDiff + + + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +

+ @if (virtualize) + { + Only visible rows and a small buffer are rendered. Wrapping is off, and the viewport has a fixed height. + } + else + { + All rows are rendered, even outside the height limit. Large samples can take longer to display. Use Show more to remove the height limit. + } +

+ +
+ @if (isLoading) + { + + @loadingMessage + @if (generatedRecordCount > 0) + { + Keeping the previous comparison until both inputs are ready. + } + } + else + { + Showing @generatedRecordCount.ToString("N0") original records. + @if (recordCount != generatedRecordCount) + { + @recordCount.ToString("N0") selected — choose Generate comparison to apply. + } + } +
+ + +
+ +
+
+ +

+ @if (WrapLongLines) + { + Source line breaks are preserved. Turn wrapping off to keep long records on one row and scroll horizontally. + } + else + { + Scroll either pane horizontally or vertically; the other follows within its scrollable range. You can also focus a pane and use the arrow keys. + } +

+ +
+ Which rendering mode should I use? Performance, selection & scrolling +
+
+
Virtualized: fewer rows in the page
+
Only visible rows and a small buffer exist in the DOM. Rows have a fixed height and never wrap. MaxHeight sets the viewport height; CollapseContent is ignored, so there is no expand button.
+
All rows: the complete text is rendered
+
Turn virtualization off for full-document browser find, selection, or printing. Expand the view with Show more when you need to see the full result. Rendering 20,000 records in this mode can pause the page.
+
Rendering is not diff calculation
+
Both modes compute the full diff up front and keep its model in memory. Virtualization reduces rendering work; it does not speed up the comparison algorithm or move it off the UI thread.
+
Wrapping is a display choice
+
Real line breaks always remain intact. Without wrapping, both panes stay side by side and synchronize scrolling on both axes. Wrapped panes can stack on small screens.
+
+

Switching rendering modes, wrapping, or viewport height reuses the existing diff. Only new text or different ignore options require another comparison.

+
+
+ + +

Keep isLoading true while preparing either input. DeferDiff retains the previous result, then compares the latest texts when loading finishes.

+
+ +
+ How the sample is generated Local, repeatable data +
+

Each JSON record occupies one long source line. The modified document changes the first record's status, removes record 3, and adds a record after record 5. More changes repeat throughout the document, so you can check alignment after scrolling.

+

Generation prepares the original text, yields to the browser, then prepares the modified text. During those stages, DeferDiff prevents an intermediate comparison. Generating again with the same size produces the same texts.

+

The selected count is the number of original records, not diff rows. Added records, removed records, and the JSON brackets affect the line totals and aligned row count.

+
+
+ +

Want a smaller example? Return to the playground and edit your own texts.

+ +@code { + private int recordCount = 1000; + private int generatedRecordCount; + private int viewportHeight = 300; + private bool virtualize = true; + private bool wrapLines = true; + private bool isLoading = true; + private string loadingMessage = "Preparing the original JSON…"; + private string? oldText; + private string? newText; + + // Retain the user's wrapping preference, but show its effective state while virtualized. + private bool WrapLongLines + { + get => !virtualize && wrapLines; + set => wrapLines = value; + } + + private string Usage => $@""; + + protected override Task OnInitializedAsync() => GenerateAsync(); + + private async Task GenerateAsync() + { + isLoading = true; + loadingMessage = "Preparing the original JSON…"; + StateHasChanged(); + + // Yield to the browser between stages so the loading state can be painted. + await Task.Delay(1); + oldText = CreateDocument(recordCount, modified: false); + loadingMessage = "Original ready. Preparing the modified JSON…"; + StateHasChanged(); + + await Task.Delay(1); + newText = CreateDocument(recordCount, modified: true); + generatedRecordCount = recordCount; + isLoading = false; + } + + private static string CreateDocument(int count, bool modified) + { + var builder = new StringBuilder("["); + for (var id = 1; id <= count; id++) + { + if (modified && (id == 3 || id % 97 == 0)) + continue; + + AppendRecord(id, modified && (id == 1 || id % 17 == 0) ? "reviewed" : "pending"); + if (modified && (id == 5 || id % 151 == 0)) + AppendRecord(count + id, "added"); + } + return builder.AppendLine().Append(']').ToString(); + + void AppendRecord(int id, string status) + { + if (builder.Length > 1) + builder.Append(','); + + // Put the changed value first so it is visible without horizontal scrolling. + builder.AppendLine() + .Append(" { \"status\": \"").Append(status) + .Append("\", \"id\": ").Append(id) + .Append(", \"name\": \"Record ").Append(id.ToString("D5")) + .Append("\", \"description\": \"A generated record with a deliberately long description to demonstrate horizontal scrolling without changing the height of the aligned diff rows.\" }"); + } + } +} diff --git a/src/BlazorTextDiff.Web/Shared/CodeExample.razor b/src/BlazorTextDiff.Web/Shared/CodeExample.razor new file mode 100644 index 0000000..164edab --- /dev/null +++ b/src/BlazorTextDiff.Web/Shared/CodeExample.razor @@ -0,0 +1,15 @@ +
+ @Title Code +
+ @ChildContent +
@Code
+
+
+ +@code { + [Parameter] public string? Id { get; set; } + [Parameter] public string Title { get; set; } = "Component code"; + [Parameter, EditorRequired] public string Code { get; set; } = string.Empty; + [Parameter] public bool Open { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/BlazorTextDiff.Web/Shared/ComparisonHeader.razor b/src/BlazorTextDiff.Web/Shared/ComparisonHeader.razor new file mode 100644 index 0000000..4e34243 --- /dev/null +++ b/src/BlazorTextDiff.Web/Shared/ComparisonHeader.razor @@ -0,0 +1,31 @@ +
+
+ Lines in full comparison + @Stats.LineModificationCount.ToString("N0") modified + @Stats.LineDeletionCount.ToString("N0") removed + @Stats.LineAdditionCount.ToString("N0") added + @if (Stats.LineModificationCount == 0 && Stats.LineDeletionCount == 0 && Stats.LineAdditionCount == 0) + { + No differences with these options. + } +
+
+
+ @OriginalLabel + Left + Top +
+
+ @ModifiedLabel + Right + Below original +
+
+
+ +@code { + [Parameter, EditorRequired] public DiffStats Stats { get; set; } = new(); + [Parameter] public bool StackOnSmallScreens { get; set; } = true; + [Parameter] public string OriginalLabel { get; set; } = "Original"; + [Parameter] public string ModifiedLabel { get; set; } = "Modified"; +} diff --git a/src/BlazorTextDiff.Web/Shared/MainLayout.razor b/src/BlazorTextDiff.Web/Shared/MainLayout.razor index 757375e..a588bd9 100644 --- a/src/BlazorTextDiff.Web/Shared/MainLayout.razor +++ b/src/BlazorTextDiff.Web/Shared/MainLayout.razor @@ -1,13 +1,23 @@ @inherits LayoutComponentBase +@inject NavigationManager Navigation +
-
-
+
+
@Body
+ +@code { + private ElementReference mainContent; + + private string SkipLink => $"{Navigation.ToBaseRelativePath(Navigation.Uri).Split('#')[0]}#main-content"; + + private Task SkipToContentAsync() => mainContent.FocusAsync().AsTask(); +} diff --git a/src/BlazorTextDiff.Web/Shared/NavMenu.razor b/src/BlazorTextDiff.Web/Shared/NavMenu.razor index b198316..5e96ac1 100644 --- a/src/BlazorTextDiff.Web/Shared/NavMenu.razor +++ b/src/BlazorTextDiff.Web/Shared/NavMenu.razor @@ -1,28 +1,41 @@ - -
-