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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A Blazor component for displaying side-by-side text differences with character-l
- Adjacent character highlights merge into smooth pill shapes
- Opt-in viewport virtualization for large documents
- Collapse/expand the comparison viewport
- Hide unchanged regions with configurable context and reveal buttons
- Ignore case and whitespace options
- Custom header with diff statistics
- Custom CSS class and attribute support
Expand Down Expand Up @@ -87,6 +88,8 @@ No manual script tags or service registration are required. Virtualized and nonw
| `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 |
| `HideUnchangedLines` | `bool` | `false` | Fold unchanged regions outside the context around changes; reveal a block from either pane |
| `ContextLines` | `int` | `3` | Unchanged lines to retain before and after each change when folding; must be nonnegative |
| `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 |
Expand Down Expand Up @@ -116,6 +119,23 @@ The component reuses its last diff when the text values and ignore options have

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.

### Hide Unchanged Lines

```razor
<TextDiff OldText="@oldText"
NewText="@newText"
HideUnchangedLines="true"
ContextLines="3" />
```

Regions that are unchanged on both sides are replaced by **Show N unchanged lines** buttons, keeping the requested context around every change. Revealing a block from either pane reveals it on both sides. Original line numbers and header statistics continue to describe the full comparison.

`ContextLines="0"` shows only changed lines and hidden-region buttons. An entirely unchanged comparison becomes one revealable block per pane. Empty-side comparisons still show additions or deletions, and the ignore options determine which lines count as unchanged.

Revealed blocks remain open during ordinary parent rerenders and wrapping, height, or virtualization changes. New comparison inputs, ignore options, or folding settings reset the hidden regions. While `DeferDiff` is true, folding options apply to the last completed comparison.

Folding works in wrapped, nonwrapping, and virtualized modes. It is separate from `CollapseContent`, which only limits the viewport height. Virtualization renders the folded row list, including fixed-height hidden-region buttons, rather than leaving gaps for hidden source lines. Neither option avoids calculating the full diff.

### Nonwrapping Comparisons

Actual newline characters are preserved in every mode. `WrapLines` controls only whether a long source line wraps visually.
Expand Down Expand Up @@ -146,7 +166,7 @@ The panes have a fixed viewport height controlled by `MaxHeight`. `CollapseConte

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.
Offscreen and folded rows are not present in the DOM, so browser find, text selection, and printing cannot include them. Turn virtualization and `HideUnchangedLines` 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.

Expand Down
24 changes: 23 additions & 1 deletion src/BlazorTextDiff.Web/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ else
CollapseContent="@collapseContent"
MaxHeight="@maxHeight"
WrapLines="@wrapLines"
HideUnchangedLines="@hideUnchangedLines"
ContextLines="@contextLines"
IgnoreCase="@ignoreCase"
IgnoreWhiteSpace="@ignoreWhiteSpace"
Class="demo-diff"
Expand Down Expand Up @@ -96,7 +98,7 @@ else
</details>

<details id="display-options" class="demo-disclosure">
<summary>Display options <span class="summary-hint">Wrapping &amp; height</span></summary>
<summary>Display options <span class="summary-hint">Wrapping, height &amp; unchanged lines</span></summary>
<div class="disclosure-body">
<div class="controls-row">
<div class="check-control">
Expand All @@ -117,6 +119,22 @@ else
</div>
</div>
<p id="display-help" class="helper-text control-hint">Wrapping changes only how long lines fit on screen; source line breaks stay intact. A height limit adds <strong>Show more</strong> / <strong>Show less</strong> controls. It does not hide unchanged lines.</p>
<div class="controls-row">
<div class="check-control">
<input id="playground-hide-unchanged" type="checkbox" @bind="hideUnchangedLines" aria-describedby="playground-folding-help" />
<label for="playground-hide-unchanged">Hide unchanged lines</label>
</div>
<div class="control-field">
<label for="playground-context" class="control-label">Context lines</label>
<select id="playground-context" class="form-control form-control-sm" @bind="contextLines" disabled="@(!hideUnchangedLines)">
<option value="0">0</option>
<option value="1">1</option>
<option value="3">3</option>
<option value="5">5</option>
</select>
</div>
</div>
<p id="playground-folding-help" class="helper-text control-hint">Keep this many unchanged lines around each change and fold the rest. A <strong>Show unchanged lines</strong> button reveals a block in both panes. Try zero context on a short sample; line numbers and totals still refer to the full comparison.</p>
<p class="helper-text">For large documents and synchronized scrolling, try the <a href="large-files">large-file demo</a>.</p>
</div>
</details>
Expand Down Expand Up @@ -205,6 +223,8 @@ RetryLimit = 3")
private bool wrapLines = true;
private bool collapseContent;
private int maxHeight = 360;
private bool hideUnchangedLines;
private int contextLines = 3;

private Sample SelectedSample => Samples.First(sample => sample.Id == sampleId);
private bool HasPendingEdits => draftOldText != oldText || draftNewText != newText;
Expand All @@ -228,6 +248,8 @@ RetryLimit = 3")
IgnoreCase=""{ignoreCase.ToString().ToLowerInvariant()}""
IgnoreWhiteSpace=""{ignoreWhiteSpace.ToString().ToLowerInvariant()}""
WrapLines=""{wrapLines.ToString().ToLowerInvariant()}""
HideUnchangedLines=""{hideUnchangedLines.ToString().ToLowerInvariant()}""
ContextLines=""{contextLines}""
CollapseContent=""{collapseContent.ToString().ToLowerInvariant()}""
MaxHeight=""{maxHeight}"" />";

Expand Down
35 changes: 32 additions & 3 deletions src/BlazorTextDiff.Web/Pages/LargeFiles.razor
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
}
else
{
<span>All rows are rendered, even outside the height limit. Large samples can take longer to display. Use <strong>Show more</strong> to remove the height limit.</span>
<span>All displayed rows are rendered, even outside the height limit. Large samples can take longer to display. Use <strong>Show more</strong> to remove the height limit.</span>
}
</p>

Expand Down Expand Up @@ -75,6 +75,8 @@
DeferDiff="@isLoading"
Virtualize="@virtualize"
WrapLines="@WrapLongLines"
HideUnchangedLines="@hideUnchangedLines"
ContextLines="@contextLines"
MaxHeight="@viewportHeight"
CollapseContent="true"
Class="demo-diff"
Expand All @@ -96,14 +98,37 @@
}
</p>

<details id="large-file-folding" class="demo-disclosure">
<summary>Hide unchanged lines <span class="summary-hint">Focus on changes and nearby context</span></summary>
<div class="disclosure-body">
<div class="controls-row">
<div class="check-control">
<input id="large-file-hide-unchanged" type="checkbox" @bind="hideUnchangedLines" disabled="@isLoading" aria-describedby="large-file-folding-help" />
<label for="large-file-hide-unchanged">Hide unchanged lines</label>
</div>
<div class="control-field">
<label for="large-file-context" class="control-label">Context lines</label>
<select id="large-file-context" class="form-control form-control-sm" @bind="contextLines" disabled="@(isLoading || !hideUnchangedLines)">
<option value="0">0</option>
<option value="1">1</option>
<option value="3">3</option>
<option value="5">5</option>
</select>
</div>
</div>
<p id="large-file-folding-help" class="helper-text control-hint">Keep unchanged context before and after each change. The remaining blocks become buttons that reveal their lines in both panes. This works with or without virtualization and does not change the line numbers or full-comparison totals.</p>
<p class="helper-text">Revealed blocks stay open while changing wrapping or viewport height. New texts, ignore options, or folding settings reset the hidden regions.</p>
</div>
</details>

<details id="rendering-guide" class="demo-disclosure">
<summary>Which rendering mode should I use? <span class="summary-hint">Performance, selection &amp; scrolling</span></summary>
<div class="disclosure-body">
<dl class="note-list">
<dt>Virtualized: fewer rows in the page</dt>
<dd>Only visible rows and a small buffer exist in the DOM. Rows have a fixed height and never wrap. <code>MaxHeight</code> sets the viewport height; <code>CollapseContent</code> is ignored, so there is no expand button.</dd>
<dt>All rows: the complete text is rendered</dt>
<dd>Turn virtualization off for full-document browser find, selection, or printing. Expand the view with <strong>Show more</strong> when you need to see the full result. Rendering 20,000 records in this mode can pause the page.</dd>
<dt>Nonvirtualized: all displayed rows are rendered</dt>
<dd>Turn virtualization and <strong>Hide unchanged lines</strong> off for full-document browser find, selection, or printing. Expand the view with <strong>Show more</strong> when you need to see the full result. Rendering 20,000 records in this mode can pause the page.</dd>
<dt>Rendering is not diff calculation</dt>
<dd>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.</dd>
<dt>Wrapping is a display choice</dt>
Expand Down Expand Up @@ -134,6 +159,8 @@
private int viewportHeight = 300;
private bool virtualize = true;
private bool wrapLines = true;
private bool hideUnchangedLines;
private int contextLines = 3;
private bool isLoading = true;
private string loadingMessage = "Preparing the original JSON…";
private string? oldText;
Expand All @@ -151,6 +178,8 @@
DeferDiff=""@isLoading""
Virtualize=""{virtualize.ToString().ToLowerInvariant()}""
WrapLines=""{WrapLongLines.ToString().ToLowerInvariant()}""
HideUnchangedLines=""{hideUnchangedLines.ToString().ToLowerInvariant()}""
ContextLines=""{contextLines}""
CollapseContent=""true""
MaxHeight=""{viewportHeight}"" />";

Expand Down
17 changes: 17 additions & 0 deletions src/BlazorTextDiff/DiffDisplayRow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace BlazorTextDiff;

/// <summary>
/// An aligned source line or a summary of consecutive hidden unchanged lines.
/// </summary>
public sealed class DiffDisplayRow
{
public int LineIndex { get; }
public int HiddenLineCount { get; }
public bool IsHidden => HiddenLineCount > 0;

internal DiffDisplayRow(int lineIndex, int hiddenLineCount = 0)
{
LineIndex = lineIndex;
HiddenLineCount = hiddenLineCount;
}
}
8 changes: 6 additions & 2 deletions src/BlazorTextDiff/ScrollableTextDiff.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
<div class="diff-pane">
<TextDiffPane Model="@Model.OldText" PanePosition="@PanePosition.Left"
Virtualize="@Virtualize" WrapLines="false" CollapseContent="@CollapseContent"
MaxHeight="@MaxHeight" @ref="leftPane" />
MaxHeight="@MaxHeight" DisplayRows="@DisplayRows"
OnShowHiddenLines="@OnShowHiddenLines" @ref="leftPane" />
</div>
<div class="diff-pane">
<TextDiffPane Model="@Model.NewText" PanePosition="@PanePosition.Right"
Virtualize="@Virtualize" WrapLines="false" CollapseContent="@CollapseContent"
MaxHeight="@MaxHeight" @ref="rightPane" />
MaxHeight="@MaxHeight" DisplayRows="@DisplayRows"
OnShowHiddenLines="@OnShowHiddenLines" @ref="rightPane" />
</div>
</div>

Expand All @@ -21,6 +23,8 @@
[Parameter] public bool Virtualize { get; set; }
[Parameter] public bool CollapseContent { get; set; }
[Parameter] public int MaxHeight { get; set; } = 300;
[Parameter] public ICollection<DiffDisplayRow>? DisplayRows { get; set; }
[Parameter] public EventCallback<DiffDisplayRow> OnShowHiddenLines { get; set; }

private TextDiffPane leftPane = default!;
private TextDiffPane rightPane = default!;
Expand Down
113 changes: 108 additions & 5 deletions src/BlazorTextDiff/TextDiff.razor
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
@if ((Virtualize || !WrapLines) && diff is not null)
{
<ScrollableTextDiff Model="diff" Virtualize="@Virtualize"
CollapseContent="@isCollapsed" MaxHeight="@MaxHeight" />
CollapseContent="@isCollapsed" MaxHeight="@MaxHeight"
DisplayRows="@displayRows" OnShowHiddenLines="@ShowHiddenLines" />
}
else
{
Expand All @@ -23,10 +24,12 @@
@if (diff is not null)
{
<div class="diff-pane">
<TextDiffPane Model="diff.OldText" PanePosition="@PanePosition.Left"></TextDiffPane>
<TextDiffPane Model="diff.OldText" PanePosition="@PanePosition.Left"
DisplayRows="@displayRows" OnShowHiddenLines="@ShowHiddenLines" />
</div>
<div class="diff-pane">
<TextDiffPane Model="diff.NewText" PanePosition="@PanePosition.Right"></TextDiffPane>
<TextDiffPane Model="diff.NewText" PanePosition="@PanePosition.Right"
DisplayRows="@displayRows" OnShowHiddenLines="@ShowHiddenLines" />
</div>
}
</div>
Expand Down Expand Up @@ -74,6 +77,18 @@
/// </summary>
[Parameter] public bool WrapLines { get; set; } = true;

/// <summary>
/// Hide unchanged regions outside the context around changes.
/// Hidden regions can be revealed from either pane.
/// </summary>
[Parameter] public bool HideUnchangedLines { get; set; }

/// <summary>
/// The number of unchanged lines to retain before and after each change.
/// Must be nonnegative. Only affects display when HideUnchangedLines is true.
/// </summary>
[Parameter] public int ContextLines { get; set; } = 3;

/// <summary>
/// The text before any changes.
/// </summary>
Expand Down Expand Up @@ -109,6 +124,11 @@
private bool isCollapsed;
private bool? previousCollapseContent;
private DiffInputs? previousInputs;
private SideBySideDiffModel? visibilityModel;
private bool? previousHideUnchangedLines;
private int previousContextLines;
private bool[]? visibleLines;
private List<DiffDisplayRow>? displayRows;

private readonly record struct DiffInputs(
string OldText, string NewText, bool IgnoreCase, bool IgnoreWhiteSpace);
Expand All @@ -120,16 +140,22 @@
{
if (Virtualize)
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxHeight);
ArgumentOutOfRangeException.ThrowIfNegative(ContextLines);

if (previousCollapseContent != CollapseContent)
{
isCollapsed = CollapseContent;
previousCollapseContent = CollapseContent;
}

if (DeferDiff)
return;
if (!DeferDiff)
UpdateDiff();

UpdateVisibility();
}

private void UpdateDiff()
{
var inputs = new DiffInputs(
OldText ?? string.Empty, NewText ?? string.Empty, IgnoreCase, IgnoreWhiteSpace);
if (previousInputs == inputs)
Expand Down Expand Up @@ -162,6 +188,83 @@
previousInputs = inputs;
}

private void UpdateVisibility()
{
if (ReferenceEquals(visibilityModel, diff)
&& previousHideUnchangedLines == HideUnchangedLines
&& previousContextLines == ContextLines)
return;

if (diff is null || !HideUnchangedLines)
{
visibleLines = null;
displayRows = null;
}
else
{
var count = diff.OldText.Lines.Count;
visibleLines = new bool[count];
var lastChange = -1;

// Two linear passes avoid repeatedly scanning overlapping context ranges.
for (var i = 0; i < count; i++)
{
if (IsChangedRow(i))
lastChange = i;
visibleLines[i] = lastChange >= 0 && i - lastChange <= ContextLines;
}

var nextChange = -1;
for (var i = count - 1; i >= 0; i--)
{
if (IsChangedRow(i))
nextChange = i;
if (nextChange >= 0 && nextChange - i <= ContextLines)
visibleLines[i] = true;
}

displayRows = BuildDisplayRows(visibleLines);

bool IsChangedRow(int index) =>
diff.OldText.Lines[index].Type != ChangeType.Unchanged
|| diff.NewText.Lines[index].Type != ChangeType.Unchanged;
}

visibilityModel = diff;
previousHideUnchangedLines = HideUnchangedLines;
previousContextLines = ContextLines;
}

private static List<DiffDisplayRow> BuildDisplayRows(bool[] visibility)
{
var rows = new List<DiffDisplayRow>();
for (var i = 0; i < visibility.Length;)
{
if (visibility[i])
{
rows.Add(new DiffDisplayRow(i));
i++;
continue;
}

var start = i;
while (i < visibility.Length && !visibility[i])
i++;
rows.Add(new DiffDisplayRow(start, i - start));
}
return rows;
}

private void ShowHiddenLines(DiffDisplayRow row)
{
// A queued click may belong to a comparison or context setting that was replaced.
if (visibleLines is null || displayRows is null || !row.IsHidden || !displayRows.Contains(row))
return;

Array.Fill(visibleLines, true, row.LineIndex, row.HiddenLineCount);
displayRows = BuildDisplayRows(visibleLines);
}

/// <summary>
/// For lines whose only sub-piece changes are whitespace characters,
/// downgrade them to Unchanged so they don't show as modified.
Expand Down
Loading
Loading