diff --git a/README.md b/README.md
index dedcff4..80b1c6c 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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 |
@@ -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
+ 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. Keep this many unchanged lines around each change and fold the rest. A Show unchanged lines button reveals a block in both panes. Try zero context on a short sample; line numbers and totals still refer to the full comparison. For large documents and synchronized scrolling, try the large-file demo.Display options Wrapping & height
+ Display options Wrapping, height & unchanged lines
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.
+Revealed blocks stay open while changing wrapping or viewport height. New texts, ignore options, or folding settings reset the hidden regions.
+MaxHeight sets the viewport height; CollapseContent is ignored, so there is no expand button.| + | + + | +
|
@((MarkupString)(diffLine.Position?.ToString() ?? " "))
diff --git a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css
index d8e0277..4979a6f 100644
--- a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css
+++ b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css
@@ -555,3 +555,32 @@ table.diff td.line-number:focus {
word-break: normal;
overflow-wrap: normal;
}
+
+.diff-container table.diff .diff-hidden-summary td {
+ background-color: var(--diff-bg-tertiary);
+ color: var(--diff-text-muted);
+}
+
+.diff-show-hidden {
+ max-width: 100%;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--diff-text-accent);
+ font: inherit;
+ line-height: inherit;
+ white-space: inherit;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.diff-show-hidden:hover {
+ color: var(--diff-text-primary);
+}
+
+.diff-show-hidden:focus-visible {
+ outline: 2px solid var(--diff-text-accent);
+ outline-offset: 2px;
+}
diff --git a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css
index d794353..5e93df4 100644
--- a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css
+++ b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css
@@ -1,2 +1,3 @@
:root{--diff-bg-primary:#ffffff;--diff-bg-secondary:#f6f8fa;--diff-bg-tertiary:#f1f3f4;--diff-border-primary:#e1e4e8;--diff-border-secondary:#30363d;--diff-text-primary:#24292e;--diff-text-secondary:#586069;--diff-text-muted:#656d76;--diff-text-accent:#0969da;--diff-addition-bg:#e6ffed;--diff-addition-border:#2ea043;--diff-addition-highlight:#7ce89b;--diff-deletion-bg:#ffeef0;--diff-deletion-border:#f85149;--diff-deletion-highlight:#f9a8b0;--diff-modification-bg:#fff8c5;--diff-modification-border:#fb8500;--diff-modification-highlight:#ffc833;--diff-char-radius:3px;--diff-char-padding:1px 2px;--diff-word-radius:3px;--diff-word-padding:1px 0;--diff-shadow:0 1px 3px rgba(0,0,0,0.1);--diff-shadow-hover:0 2px 6px rgba(0,0,0,0.15);--stats-primary-bg:#0969da;--stats-primary-text:#ffffff;--stats-success-bg:#2ea043;--stats-success-text:#ffffff;--stats-danger-bg:#f85149;--stats-danger-text:#ffffff;--stats-warning-bg:#fb8500;--stats-warning-text:#ffffff;--stats-info-bg:#0ea5e9;--stats-info-text:#ffffff}@media(prefers-color-scheme:dark){:root{--diff-bg-primary:#0d1117;--diff-bg-secondary:#161b22;--diff-bg-tertiary:#21262d;--diff-border-primary:#30363d;--diff-border-secondary:#21262d;--diff-text-primary:#f0f6fc;--diff-text-secondary:#8b949e;--diff-text-muted:#6e7681;--diff-text-accent:#58a6ff;--diff-addition-bg:#033a16;--diff-addition-border:#2ea043;--diff-addition-highlight:#196c2e;--diff-deletion-bg:#490202;--diff-deletion-border:#f85149;--diff-deletion-highlight:#c93c37;--diff-modification-bg:#341a00;--diff-modification-border:#fb8500;--diff-modification-highlight:#7d5200;--diff-shadow:0 1px 3px rgba(0,0,0,0.3);--diff-shadow-hover:0 2px 6px rgba(0,0,0,0.4);--stats-primary-bg:#1f6feb;--stats-success-bg:#238636;--stats-danger-bg:#da3633;--stats-warning-bg:#fb8500;--stats-info-bg:#0ea5e9}}.diff-container{background-color:var(--diff-bg-primary);border:1px solid var(--diff-border-primary);border-radius:8px;overflow:hidden;box-shadow:var(--diff-shadow);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Roboto',sans-serif;transition:box-shadow .2s ease-in-out}.diff-container:hover{box-shadow:var(--diff-shadow-hover)}.diff-container .diff-expand-notice{display:block;width:100%;text-align:center;border:none;border-top:1px solid var(--diff-border-primary);margin:0;background-color:var(--diff-bg-secondary);padding:10px 12px;color:var(--diff-text-secondary);font-size:13px;font-weight:500;cursor:pointer;transition:background-color .15s ease-in-out,color .15s ease-in-out}.diff-container .diff-expand-notice:hover{background-color:var(--diff-bg-tertiary);color:var(--diff-text-accent)}.diff-container .diff-expand-notice:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-header{border-bottom:1px solid var(--diff-border-primary);background:linear-gradient(135deg,var(--diff-bg-secondary) 0%,var(--diff-bg-tertiary) 100%);border-top-left-radius:8px;border-top-right-radius:8px;display:flex;align-items:center;justify-content:space-between;min-height:50px;padding:0 16px}.diff-container .diff-footer{border-top:1px solid var(--diff-border-primary);background:linear-gradient(135deg,var(--diff-bg-secondary) 0%,var(--diff-bg-tertiary) 100%);border-bottom-left-radius:8px;border-bottom-right-radius:8px;display:flex;align-items:center;justify-content:space-between;min-height:50px;padding:0 16px}.diff-container .diff-panes{display:flex;background-color:var(--diff-bg-primary)}.diff-container .diff-panes .diff-pane{flex:1;border-right:1px solid var(--diff-border-primary)}.diff-container .diff-panes .diff-pane:last-child{border-right:none}.diff-container .diff-panes .diff-pane .line-text{word-break:break-all;word-wrap:break-word}.diff-stats-container{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.diff-stats-badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:6px;font-size:12px;font-weight:500;line-height:1.2;white-space:nowrap;transition:all .2s ease-in-out;border:1px solid transparent}.diff-stats-badge:hover{transform:translateY(-1px);box-shadow:var(--diff-shadow)}.diff-stats-badge.primary{background-color:var(--stats-primary-bg);color:var(--stats-primary-text)}.diff-stats-badge.success{background-color:var(--stats-success-bg);color:var(--stats-success-text)}.diff-stats-badge.danger{background-color:var(--stats-danger-bg);color:var(--stats-danger-text)}.diff-stats-badge.warning{background-color:var(--stats-warning-bg);color:var(--stats-warning-text)}.diff-stats-badge.info{background-color:var(--stats-info-bg);color:var(--stats-info-text)}.diff-stats-badge .badge-icon{margin-right:4px;font-size:10px}.diff-pane-left{margin:0;padding:0;overflow:auto;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:14px;line-height:1.5}.diff-pane-left .line-number.modified{background-color:var(--diff-modification-bg);border-right:3px solid var(--diff-modification-border)}.diff-pane-left .line-number.deleted{background-color:var(--diff-deletion-bg);border-right:3px solid var(--diff-deletion-border)}.diff-pane-left .deleted-line{background-color:var(--diff-deletion-bg);border-left:3px solid var(--diff-deletion-border)}.diff-pane-left .modified-line{background-color:var(--diff-modification-bg);border-left:3px solid var(--diff-modification-border)}.diff-pane-right{margin:0;padding:0;overflow:auto;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:14px;line-height:1.5}.diff-pane-right .line-number.modified{background-color:var(--diff-modification-bg);border-right:3px solid var(--diff-modification-border)}.diff-pane-right .line-number.inserted{background-color:var(--diff-addition-bg);border-right:3px solid var(--diff-addition-border)}.diff-pane-right .modified-line{background-color:var(--diff-modification-bg);border-left:3px solid var(--diff-modification-border)}.diff-pane-right .inserted-line{background-color:var(--diff-addition-bg);border-left:3px solid var(--diff-addition-border)}.diff-pane-right .deleted-line{background-color:var(--diff-deletion-bg);border-left:3px solid var(--diff-deletion-border)}.diff-pane-right .inserted-word,.diff-pane-left .inserted-word{background-color:var(--diff-addition-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .modified-word,.diff-pane-left .modified-word{background-color:var(--diff-modification-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .deleted-word,.diff-pane-left .deleted-word{background-color:var(--diff-deletion-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .inserted-character,.diff-pane-left .inserted-character{background-color:var(--diff-addition-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .modified-character,.diff-pane-left .modified-character{background-color:var(--diff-modification-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .deleted-character,.diff-pane-left .deleted-character{background-color:var(--diff-deletion-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.inserted-character+.inserted-character,.modified-character+.modified-character,.deleted-character+.deleted-character{border-top-left-radius:0;border-bottom-left-radius:0;padding-left:0}.inserted-character:has(+.inserted-character),.modified-character:has(+.modified-character),.deleted-character:has(+.deleted-character){border-top-right-radius:0;border-bottom-right-radius:0;padding-right:0}table.diff{background-color:var(--diff-bg-primary);width:100%;border-collapse:collapse;margin:0;border-spacing:0}table.diff td.line-number{width:1%;min-width:60px;padding:4px 12px;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:12px;line-height:22px;color:var(--diff-text-muted);text-align:right;white-space:nowrap;vertical-align:top;cursor:pointer;user-select:none;background-color:var(--diff-bg-secondary);border-right:1px solid var(--diff-border-primary);font-weight:400;transition:color .2s ease-in-out,background-color .2s ease-in-out}table.diff td.line-number:hover{color:var(--diff-text-accent);background-color:var(--diff-bg-tertiary)}table.diff td.line{padding:4px 12px;line-height:22px;vertical-align:top;font-size:14px;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;white-space:pre-wrap;word-wrap:break-word;color:var(--diff-text-primary)}table.diff td .unchanged-line{background-color:var(--diff-bg-primary)}table.diff tr:hover td.line-number{background-color:var(--diff-bg-tertiary)}table.diff tr:hover td.line{background-color:color-mix(in srgb,var(--diff-border-primary) 30%,transparent)}@media(max-width:768px){.diff-container .diff-panes{flex-direction:column}.diff-container .diff-panes .diff-pane{border-right:none;border-bottom:1px solid var(--diff-border-primary)}.diff-container .diff-panes .diff-pane:last-child{border-bottom:none}table.diff td.line-number{min-width:50px;padding:2px 8px;font-size:11px}table.diff td.line{padding:2px 8px;font-size:13px}.diff-stats-container{gap:4px}.diff-stats-badge{font-size:11px;padding:3px 6px}}@media(prefers-reduced-motion:reduce){.diff-container,.diff-stats-badge,table.diff td.line-number,table.diff td.line{transition:none}.diff-container .diff-header,.diff-container .diff-footer{background:var(--diff-bg-secondary)}}table.diff td.line-number:focus{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-stats-badge:focus{outline:2px solid var(--diff-text-accent);outline-offset:2px}@media print{.diff-container{box-shadow:none;border:1px solid #000}.diff-stats-badge{border:1px solid #000;background-color:transparent!important;color:#000!important}}
-.diff-container .diff-panes.diff-scroll-panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.diff-container .diff-panes.diff-scroll-panes>.diff-pane{min-width:0;border-right:1px solid var(--diff-border-primary);border-bottom:none}.diff-container .diff-panes.diff-scroll-panes>.diff-pane:last-child{border-right:none}.diff-container .diff-scroll-pane{overflow-x:scroll;overflow-y:auto;scrollbar-gutter:stable;overscroll-behavior:contain}.diff-container .diff-scroll-pane:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-scroll-pane table.diff{width:max-content;min-width:100%}.diff-container .diff-virtualized-pane table.diff .diff-row,.diff-container .diff-virtualized-pane table.diff td{height:var(--diff-virtual-row-height)}.diff-container .diff-virtualized-pane{overflow-anchor:none}.diff-container .diff-virtualized-pane table.diff td{box-sizing:border-box;padding:4px 12px;line-height:calc(var(--diff-virtual-row-height) - 8px)}.diff-container .diff-scroll-pane table.diff td.line-number{position:sticky;left:0;z-index:1;box-sizing:border-box;width:60px;min-width:60px}.diff-container .diff-scroll-pane table.diff td.line,.diff-container .diff-panes .diff-scroll-pane .line-text{white-space:pre;word-break:normal;overflow-wrap:normal}
\ No newline at end of file
+.diff-container .diff-panes.diff-scroll-panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.diff-container .diff-panes.diff-scroll-panes>.diff-pane{min-width:0;border-right:1px solid var(--diff-border-primary);border-bottom:none}.diff-container .diff-panes.diff-scroll-panes>.diff-pane:last-child{border-right:none}.diff-container .diff-scroll-pane{overflow-x:scroll;overflow-y:auto;scrollbar-gutter:stable;overscroll-behavior:contain}.diff-container .diff-scroll-pane:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-scroll-pane table.diff{width:max-content;min-width:100%}.diff-container .diff-virtualized-pane table.diff .diff-row,.diff-container .diff-virtualized-pane table.diff td{height:var(--diff-virtual-row-height)}.diff-container .diff-virtualized-pane{overflow-anchor:none}.diff-container .diff-virtualized-pane table.diff td{box-sizing:border-box;padding:4px 12px;line-height:calc(var(--diff-virtual-row-height) - 8px)}.diff-container .diff-scroll-pane table.diff td.line-number{position:sticky;left:0;z-index:1;box-sizing:border-box;width:60px;min-width:60px}.diff-container .diff-scroll-pane table.diff td.line,.diff-container .diff-panes .diff-scroll-pane .line-text{white-space:pre;word-break:normal;overflow-wrap:normal}
+.diff-container table.diff .diff-hidden-summary td{background-color:var(--diff-bg-tertiary);color:var(--diff-text-muted)}.diff-show-hidden{max-width:100%;margin:0;padding:0;border:0;background:none;color:var(--diff-text-accent);font:inherit;line-height:inherit;white-space:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.diff-show-hidden:hover{color:var(--diff-text-primary)}.diff-show-hidden:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:2px}
\ No newline at end of file
diff --git a/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs b/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs
new file mode 100644
index 0000000..efc58eb
--- /dev/null
+++ b/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs
@@ -0,0 +1,283 @@
+using Bunit;
+using DiffPlex.DiffBuilder.Model;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web.Virtualization;
+
+namespace BlazorTextDiff.Tests;
+
+public class TextDiffFoldingTests : TestContext
+{
+ public TextDiffFoldingTests()
+ {
+ var module = JSInterop.SetupModule("./_content/BlazorTextDiff/js/virtualizedDiff.js");
+ var connection = module.SetupModule(call => call.Identifier == "connect");
+ connection.SetupVoid("dispose").SetVoidResult();
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void HidingUnchangedLines_PreservesContextAndOriginalLineNumbers(bool virtualize, bool wrapLines)
+ {
+ var cut = RenderComparison(virtualize, wrapLines);
+
+ cut.WaitForAssertion(() => Assert.Equal(4, cut.FindAll(".diff-hidden-summary").Count));
+ Assert.Equal(10, cut.FindAll("tr.diff-row").Count);
+ Assert.Equal(new[] { "Show 3 unchanged lines", "Show 4 unchanged lines" },
+ cut.FindAll(".diff-pane-left .diff-show-hidden").Select(button => button.TextContent));
+ Assert.Equal(new[] { "Line 4", "Line 5", "Line 6" },
+ cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent));
+ Assert.Equal(new[] { "Line 4", "Modified Line 5", "Line 6" },
+ cut.FindAll(".diff-pane-right .line-text").Select(line => line.TextContent));
+ Assert.Equal(new[] { "4", "5", "6" },
+ cut.FindAll(".diff-pane-left tr:not(.diff-hidden-summary) .line-number")
+ .Select(number => number.TextContent.Trim()));
+
+ var panes = cut.FindComponents |