diff --git a/components/MarkdownTextBlock/samples/MarkdownTextBlock.md b/components/MarkdownTextBlock/samples/MarkdownTextBlock.md index 87b2bde3e..9d419cbac 100644 --- a/components/MarkdownTextBlock/samples/MarkdownTextBlock.md +++ b/components/MarkdownTextBlock/samples/MarkdownTextBlock.md @@ -38,5 +38,4 @@ Try typing markdown and see it rendered in real-time: ## Custom theme sample Try different styling options for all markdown elements with a custom theme: -> [!Sample MarkdownTextBlockCustomThemeSample] - +> [!Sample MarkdownTextBlockCustomThemeSample] \ No newline at end of file diff --git a/components/MarkdownTextBlock/samples/MarkdownTextBlockCustomThemeSample.xaml.cs b/components/MarkdownTextBlock/samples/MarkdownTextBlockCustomThemeSample.xaml.cs index 9a335dd8d..127efbee0 100644 --- a/components/MarkdownTextBlock/samples/MarkdownTextBlockCustomThemeSample.xaml.cs +++ b/components/MarkdownTextBlock/samples/MarkdownTextBlockCustomThemeSample.xaml.cs @@ -40,6 +40,8 @@ This sample demonstrates the **custom theming** capabilities of the `MarkdownTex The image above automatically scales to respect the max width setting while maintaining its aspect ratio. Text continues to flow seamlessly below it. +![Shortcut Conflict](https://devblogs.microsoft.com/commandline/wp-content/uploads/sites/33/2025/09/ShortcutConflict.png) + ## Code Blocks ```csharp diff --git a/components/MarkdownTextBlock/src/TextElements/MyImage.cs b/components/MarkdownTextBlock/src/TextElements/MyImage.cs index feb039f1d..1d100881a 100644 --- a/components/MarkdownTextBlock/src/TextElements/MyImage.cs +++ b/components/MarkdownTextBlock/src/TextElements/MyImage.cs @@ -92,10 +92,31 @@ private async void LoadImage(object sender, RoutedEventArgs e) if (_loaded) return; try { + // Track whether we have valid natural dimensions to constrain against + bool hasNaturalWidth = false; + bool hasNaturalHeight = false; + if (_imageProvider != null && _imageProvider.ShouldUseThisProvider(_uri.AbsoluteUri)) { _image = await _imageProvider.GetImage(_uri.AbsoluteUri); _container.Child = _image; + + // Capture natural dimensions as max constraints from the provider image + // Then clear fixed Width/Height so images can shrink responsively + if (_image.Width > 0 && !double.IsNaN(_image.Width) && !double.IsInfinity(_image.Width)) + { + _image.MaxWidth = _image.Width; + _image.Width = double.NaN; // Clear fixed width to allow shrinking + hasNaturalWidth = true; + } + if (_image.Height > 0 && !double.IsNaN(_image.Height) && !double.IsInfinity(_image.Height)) + { + _image.MaxHeight = _image.Height; + _image.Height = double.NaN; // Clear fixed height to allow shrinking + hasNaturalHeight = true; + } + + _loaded = true; } else { @@ -137,53 +158,50 @@ private async void LoadImage(object sender, RoutedEventArgs e) await bitmap.SetSourceAsync(stream); } _image.Source = bitmap; - _image.Width = bitmap.PixelWidth == 0 ? bitmap.DecodePixelWidth : bitmap.PixelWidth; - _image.Height = bitmap.PixelHeight == 0 ? bitmap.DecodePixelHeight : bitmap.PixelHeight; + // Don't set fixed Width/Height - let layout system handle it + // Store natural dimensions for MaxWidth/MaxHeight constraints + double naturalWidth = bitmap.PixelWidth == 0 ? bitmap.DecodePixelWidth : bitmap.PixelWidth; + double naturalHeight = bitmap.PixelHeight == 0 ? bitmap.DecodePixelHeight : bitmap.PixelHeight; + // Use natural size as max constraint so image doesn't upscale + if (naturalWidth > 0) + { + _image.MaxWidth = naturalWidth; + hasNaturalWidth = true; + } + if (naturalHeight > 0) + { + _image.MaxHeight = naturalHeight; + hasNaturalHeight = true; + } } _loaded = true; } - // Determine the actual image dimensions - double actualWidth = _precedentWidth != 0 ? _precedentWidth : _image.Width; - double actualHeight = _precedentHeight != 0 ? _precedentHeight : _image.Height; - - // Apply max constraints and calculate the final size - // When using Uniform stretch with max constraints, we need to calculate - // the actual rendered size to avoid gaps - double finalWidth = actualWidth; - double finalHeight = actualHeight; - - bool hasMaxWidth = _themes.ImageMaxWidth > 0; - bool hasMaxHeight = _themes.ImageMaxHeight > 0; - - if (hasMaxWidth || hasMaxHeight) + // Apply precedent (markdown-specified) dimensions if provided + // Precedent always takes priority and sets a known dimension + if (_precedentWidth != 0) { - double scaleX = hasMaxWidth && actualWidth > _themes.ImageMaxWidth - ? _themes.ImageMaxWidth / actualWidth - : 1.0; - double scaleY = hasMaxHeight && actualHeight > _themes.ImageMaxHeight - ? _themes.ImageMaxHeight / actualHeight - : 1.0; - - // For Uniform stretch, use the smaller scale to maintain aspect ratio - if (_themes.ImageStretch == Stretch.Uniform || _themes.ImageStretch == Stretch.UniformToFill) - { - double uniformScale = Math.Min(scaleX, scaleY); - finalWidth = actualWidth * uniformScale; - finalHeight = actualHeight * uniformScale; - } - else - { - // For other stretch modes, apply constraints independently - finalWidth = actualWidth * scaleX; - finalHeight = actualHeight * scaleY; - } + _image.MaxWidth = _precedentWidth; + hasNaturalWidth = true; + } + if (_precedentHeight != 0) + { + _image.MaxHeight = _precedentHeight; + hasNaturalHeight = true; } - _image.Width = finalWidth; - _image.Height = finalHeight; + // Apply theme constraints - only if we have a known dimension to constrain + // This prevents theme constraints from enlarging images with unknown natural size + if (_themes.ImageMaxWidth > 0 && hasNaturalWidth && _themes.ImageMaxWidth < _image.MaxWidth) + { + _image.MaxWidth = _themes.ImageMaxWidth; + } + if (_themes.ImageMaxHeight > 0 && hasNaturalHeight && _themes.ImageMaxHeight < _image.MaxHeight) + { + _image.MaxHeight = _themes.ImageMaxHeight; + } _image.Stretch = _themes.ImageStretch; } catch (Exception) { } diff --git a/components/MarkdownTextBlock/tests/ImageProviderConstraintTest.cs b/components/MarkdownTextBlock/tests/ImageProviderConstraintTest.cs new file mode 100644 index 000000000..f7d58b888 --- /dev/null +++ b/components/MarkdownTextBlock/tests/ImageProviderConstraintTest.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Tooling.TestGen; +using CommunityToolkit.Tests; +using CommunityToolkit.WinUI.Controls; + +namespace MarkdownTextBlockTests; + +/// +/// Test class to verify image constraint behavior when using IImageProvider +/// Regression test for: https://github.com/CommunityToolkit/Labs-Windows/pull/771 +/// +[TestClass] +public partial class ImageProviderConstraintTest : VisualUITestBase +{ + /// + /// Mock image provider that returns an image with specific dimensions + /// + private class TestImageProvider : IImageProvider + { + private readonly double _width; + private readonly double _height; + + public TestImageProvider(double width = 100, double height = 100) + { + _width = width; + _height = height; + } + + public bool ShouldUseThisProvider(string url) => url.StartsWith("test://"); + + public Task GetImage(string url) + { + var image = new Image + { + // Simulate a 100x100 image with natural dimensions + Width = _width, + Height = _height, + Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage() + }; + return Task.FromResult(image); + } + } + + [UIThreadTestMethod] + public async Task ImageProvider_WithThemeConstraints_ShouldNotApplyConstraintsToSmallerImages() + { + // Arrange: Image provider returns a 100x100 image + var provider = new TestImageProvider(width: 100, height: 100); + + var config = new MarkdownConfig + { + ImageProvider = provider, + Themes = new MarkdownThemes + { + // Theme allows images up to 500px wide + ImageMaxWidth = 500, + ImageMaxHeight = 500 + } + }; + + var markdown = new MarkdownTextBlock + { + Config = config, + // Image URL with no precedent dimensions specified + Text = "![Test Image](test://example.png)" + }; + + // Act + await LoadTestContentAsync(markdown); + + // Give the async image loading time to complete + await Task.Delay(500); + await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); + + // Find the image element + var image = markdown.FindDescendant(); + Assert.IsNotNull(image, "Image element should be rendered"); + + // Assert: The image should maintain its natural size (100x100) + // and NOT be constrained to the theme's larger MaxWidth (500). + // + // This is a regression test for a previous bug where, when IImageProvider was used, + // natural dimensions weren't set on the image, so MaxWidth defaulted to Infinity. + // In that case the theme constraint (500 < Infinity) incorrectly applied, forcing MaxWidth=500. + // + // The purpose of this test is to ensure that the image's MaxWidth is set to its natural size (100) + // before theme constraints are evaluated, so the theme constraint is not incorrectly applied. + + Assert.AreEqual(100.0, image.MaxWidth, 0.1, + "Image MaxWidth should be its natural size (100), not the theme constraint (500). " + + "When using IImageProvider, natural dimensions must be set before applying theme constraints."); + } + + [UIThreadTestMethod] + public async Task ImageProvider_WithPrecedentDimensions_ShouldUsePrecedent() + { + // Arrange: Image provider returns a 100x100 image + var provider = new TestImageProvider(width: 100, height: 100); + + var config = new MarkdownConfig + { + ImageProvider = provider, + Themes = new MarkdownThemes + { + ImageMaxWidth = 500, + ImageMaxHeight = 500 + } + }; + + var markdown = new MarkdownTextBlock + { + Config = config, + // Image with explicit width specified in markdown + Text = "" + }; + + // Act + await LoadTestContentAsync(markdown); + await Task.Delay(500); + await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); + + var image = markdown.FindDescendant(); + Assert.IsNotNull(image, "Image element should be rendered"); + + // Assert: Precedent dimensions should be respected + Assert.AreEqual(200.0, image.MaxWidth, 0.1, + "Image MaxWidth should respect precedent dimension (200) from markdown"); + } + + [UIThreadTestMethod] + public async Task ImageProvider_WithSmallerThemeConstraint_ShouldApplyThemeConstraint() + { + // Arrange: Image provider returns a 800x600 image + var provider = new TestImageProvider(width: 800, height: 600); + + var config = new MarkdownConfig + { + ImageProvider = provider, + Themes = new MarkdownThemes + { + // Theme constrains images to max 400px + ImageMaxWidth = 400, + ImageMaxHeight = 400 + } + }; + + var markdown = new MarkdownTextBlock + { + Config = config, + Text = "![Test Image](test://large.png)" + }; + + // Act + await LoadTestContentAsync(markdown); + await Task.Delay(500); + await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); + + var image = markdown.FindDescendant(); + Assert.IsNotNull(image, "Image element should be rendered"); + + // Assert: When natural size (800) is larger than theme constraint (400), + // the theme constraint should apply + Assert.AreEqual(400.0, image.MaxWidth, 0.1, + "Image MaxWidth should be constrained to theme max (400) when natural size (800) is larger"); + } +} diff --git a/components/MarkdownTextBlock/tests/MarkdownTextBlock.Tests.projitems b/components/MarkdownTextBlock/tests/MarkdownTextBlock.Tests.projitems index c3eab777f..5151d061e 100644 --- a/components/MarkdownTextBlock/tests/MarkdownTextBlock.Tests.projitems +++ b/components/MarkdownTextBlock/tests/MarkdownTextBlock.Tests.projitems @@ -10,6 +10,7 @@ + ExampleMarkdownTextBlockTestPage.xaml