Fix markdown rendering in Marechai.App via native XAML renderer

MarkdownTextBlock crashed with a NullReferenceException in ThemeListener
under Uno, and the WebView2-based fallback used by description pages
doesn't composite on Linux desktop. Render markdown directly to XAML
elements (TextBlock/Span/Hyperlink/Image trees) via Markdig's AST instead,
shared by MessageMarkdownView and all entity description pages, with no
embedded browser dependency on any platform.
This commit is contained in:
2026-06-30 14:16:22 +01:00
parent 643bf86bed
commit 96b211aa77
23 changed files with 380 additions and 368 deletions

View File

@@ -61,12 +61,10 @@
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls" Version="7.1.2"/>
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.Markdown" Version="7.1.2"/>
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI.Controls" Version="7.1.205"/>
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI.Controls.Markdown" Version="7.1.205"/>
<!-- Add more uno community toolkit references here -->
</ItemGroup>

View File

@@ -64,6 +64,7 @@
<PackageReference Include="Svg.Skia"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="QRCoder"/>
<PackageReference Include="Markdig"/>
</ItemGroup>
<ItemGroup>
<Compile Update="Presentation\Views\Shell.xaml.cs">
@@ -102,12 +103,10 @@
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls"/>
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls.Markdown"/>
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI.Controls"/>
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI.Controls.Markdown"/>
<!-- Add more uno community toolkit references here -->
</ItemGroup>

View File

@@ -103,7 +103,7 @@ public partial class CompanyDetailViewModel : ObservableObject, IRegionAware
private CompanyDto? _soldToCompany;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private ObservableCollection<CompanyDetailItem> _people = [];
@@ -194,7 +194,7 @@ public partial class CompanyDetailViewModel : ObservableObject, IRegionAware
/// <summary>
/// Gets whether a description is available
/// </summary>
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
/// <summary>
/// Gets whether company has multiple logos
@@ -235,7 +235,7 @@ public partial class CompanyDetailViewModel : ObservableObject, IRegionAware
OnPropertyChanged(nameof(HasLogoContent));
}
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -830,7 +830,7 @@ public partial class CompanyDetailViewModel : ObservableObject, IRegionAware
string langCode = GetIso639CodeFromCulture();
CompanyDescriptionDto? desc = await _companyDetailService.GetDescriptionAsync(CompanyId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -77,7 +77,7 @@ public partial class GpuDetailViewModel : ObservableObject, IRegionAware
private bool _hasSmartphones;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private Visibility _showDescription = Visibility.Collapsed;
@@ -97,9 +97,9 @@ public partial class GpuDetailViewModel : ObservableObject, IRegionAware
/// <summary>
/// Gets whether a description is available
/// </summary>
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -330,7 +330,7 @@ public partial class GpuDetailViewModel : ObservableObject, IRegionAware
string langCode = GetIso639CodeFromCulture();
GpuDescriptionDto? desc = await _gpusService.GetDescriptionAsync(GpuId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -62,7 +62,7 @@ public partial class MachineViewViewModel : ObservableObject, IRegionAware
private string _companyName = string.Empty;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private string _errorMessage = string.Empty;
@@ -145,9 +145,9 @@ public partial class MachineViewViewModel : ObservableObject, IRegionAware
/// <summary>
/// Gets whether a description is available
/// </summary>
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -662,7 +662,7 @@ public partial class MachineViewViewModel : ObservableObject, IRegionAware
string langCode = GetIso639CodeFromCulture();
MachineDescriptionDto? desc = await _computersService.GetDescriptionAsync(machineId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -68,7 +68,7 @@ public partial class ProcessorDetailViewModel : ObservableObject, IRegionAware
private bool _hasSmartphones;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private Visibility _showDescription = Visibility.Collapsed;
@@ -76,9 +76,9 @@ public partial class ProcessorDetailViewModel : ObservableObject, IRegionAware
/// <summary>
/// Gets whether a description is available
/// </summary>
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -270,7 +270,7 @@ public partial class ProcessorDetailViewModel : ObservableObject, IRegionAware
string langCode = GetIso639CodeFromCulture();
ProcessorDescriptionDto? desc = await _processorsService.GetDescriptionAsync(ProcessorId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -227,7 +227,7 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
private Visibility _showCredits = Visibility.Collapsed;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private Visibility _showDescription = Visibility.Collapsed;
@@ -235,12 +235,12 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
[ObservableProperty]
private ImageSource? _heroImageSource;
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
public bool HasHeroImage => HeroImageSource is not null;
public bool HasMarechaiScore => MarechaiScore.HasValue;
public bool HasMarechaiScoreRankText => !string.IsNullOrWhiteSpace(MarechaiScoreRankText);
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -707,7 +707,7 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
{
string langCode = GetIso639CodeFromCulture();
SoftwareDescriptionDto? desc = await _browsingService.GetDescriptionAsync(softwareId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -70,7 +70,7 @@ public partial class SoundSynthDetailViewModel : ObservableObject, IRegionAware
private bool _hasSmartphones;
[ObservableProperty]
private string _descriptionHtml = string.Empty;
private string _descriptionMarkdown = string.Empty;
[ObservableProperty]
private string _displayName = string.Empty;
@@ -93,9 +93,9 @@ public partial class SoundSynthDetailViewModel : ObservableObject, IRegionAware
/// <summary>
/// Gets whether a description is available
/// </summary>
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml);
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
partial void OnDescriptionHtmlChanged(string value)
partial void OnDescriptionMarkdownChanged(string value)
{
OnPropertyChanged(nameof(HasDescription));
}
@@ -290,7 +290,7 @@ public partial class SoundSynthDetailViewModel : ObservableObject, IRegionAware
string langCode = GetIso639CodeFromCulture();
SoundSynthDescriptionDto? desc = await _soundSynthsService.GetDescriptionAsync(SoundSynthId, langCode);
DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty;
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
}
catch(Exception ex)
{

View File

@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
NavigationCacheMode="Required"
@@ -856,8 +857,7 @@
HorizontalScrollBarVisibility="Disabled">
<StackPanel Padding="16"
Spacing="8">
<WebView2 x:Name="DescriptionWebView"
MinHeight="400" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
</ScrollViewer>
</TabViewItem>

View File

@@ -1,7 +1,6 @@
#nullable enable
using System;
using System.ComponentModel;
using Windows.System;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI.Xaml;
@@ -14,56 +13,6 @@ public sealed partial class CompanyDetailPage : Page
public CompanyDetailPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is CompanyDetailViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(CompanyDetailViewModel.DescriptionHtml) &&
sender is CompanyDetailViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
private async void OnTwitterClick(object sender, RoutedEventArgs e)

View File

@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
NavigationCacheMode="Required"
@@ -256,8 +257,7 @@
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=DescriptionSectionTitle}"
FontSize="20"
FontWeight="SemiBold" />
<WebView2 x:Name="DescriptionWebView"
MinHeight="200" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
</StackPanel>

View File

@@ -1,6 +1,5 @@
#nullable enable
using System.ComponentModel;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -12,56 +11,6 @@ public sealed partial class GpuDetailPage : Page
public GpuDetailPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is GpuDetailViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(GpuDetailViewModel.DescriptionHtml) &&
sender is GpuDetailViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
private void ComputersSearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)

View File

@@ -7,6 +7,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:wctui="using:CommunityToolkit.WinUI.UI.Controls"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:components="using:Marechai.App.Presentation.Components"
NavigationCacheMode="Disabled"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
@@ -396,8 +397,7 @@
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=DescriptionSectionTitle}"
FontSize="20"
FontWeight="SemiBold" />
<WebView2 x:Name="DescriptionWebView"
MinHeight="200" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
</StackPanel>

View File

@@ -1,8 +1,5 @@
#nullable enable
using System.ComponentModel;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Marechai.App.Presentation.Views;
@@ -12,55 +9,5 @@ public sealed partial class MachineViewPage : Page
public MachineViewPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is MachineViewViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(MachineViewViewModel.DescriptionHtml) &&
sender is MachineViewViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
}
}

View File

@@ -0,0 +1,337 @@
#nullable enable
using System;
using System.Linq;
using Markdig;
using Markdig.Extensions.Footnotes;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Media;
using Windows.System;
using Block = Markdig.Syntax.Block;
using Markdown = Markdig.Markdown;
namespace Marechai.App.Presentation.Views;
/// <summary>
/// Renders markdown directly into native XAML elements (TextBlock/Span/Hyperlink/Image trees) instead of going
/// through HTML and an embedded WebView. Uno's WebView2 doesn't reliably composite on every desktop backend
/// (it renders nothing on Linux/X11), so this avoids depending on a native embedded browser at all - it works
/// identically on every Uno target. <c>DisableHtml()</c> means literal HTML in the source is never interpreted,
/// just shown as plain text, so there's no markup-injection surface to sanitize.
/// </summary>
internal static class MarkdownRenderer
{
static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.DisableHtml()
.Build();
public static UIElement Render(string? markdown)
{
var root = new StackPanel { Spacing = 8 };
if(string.IsNullOrWhiteSpace(markdown)) return root;
MarkdownDocument document = Markdown.Parse(markdown, _pipeline);
foreach(Block block in document) AppendBlock(root, block);
return root;
}
static void AppendBlock(Panel container, Block block)
{
switch(block)
{
case HeadingBlock heading:
container.Children.Add(BuildHeading(heading));
break;
case QuoteBlock quote:
container.Children.Add(BuildQuote(quote));
break;
case ListBlock list:
container.Children.Add(BuildList(list));
break;
case FencedCodeBlock or CodeBlock:
container.Children.Add(BuildCodeBlock((LeafBlock)block));
break;
case ThematicBreakBlock:
container.Children.Add(new Border
{
Height = 1,
Opacity = 0.4,
Margin = new Thickness(0, 4, 0, 4),
BorderThickness = new Thickness(1),
BorderBrush = ThemeBrush("TextFillColorSecondaryBrush")
});
break;
case Table table:
container.Children.Add(BuildTable(table));
break;
case FootnoteGroup footnotes:
container.Children.Add(BuildFootnotes(footnotes));
break;
case LeafBlock { Inline: not null } leaf:
container.Children.Add(BuildParagraph(leaf.Inline));
break;
}
}
static TextBlock BuildHeading(HeadingBlock heading)
{
var textBlock = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
FontWeight = FontWeights.SemiBold,
FontSize = heading.Level switch
{
1 => 24,
2 => 20,
3 => 18,
4 => 16,
5 => 15,
_ => 14
}
};
if(heading.Inline is not null) AppendInlines(textBlock.Inlines, heading.Inline);
return textBlock;
}
static TextBlock BuildParagraph(ContainerInline? inline)
{
var textBlock = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = 14 };
if(inline is not null) AppendInlines(textBlock.Inlines, inline);
return textBlock;
}
static Border BuildQuote(QuoteBlock quote)
{
var inner = new StackPanel { Spacing = 4 };
foreach(Block child in quote) AppendBlock(inner, child);
return new Border
{
BorderBrush = ThemeBrush("TextFillColorSecondaryBrush"),
BorderThickness = new Thickness(3, 0, 0, 0),
Padding = new Thickness(8, 0, 0, 0),
Child = inner
};
}
static StackPanel BuildList(ListBlock list)
{
var panel = new StackPanel { Spacing = 4 };
int index = list.IsOrdered && int.TryParse(list.OrderedStart, out int start) ? start : 1;
foreach(Block item in list)
{
if(item is not ListItemBlock listItem) continue;
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 };
row.Children.Add(new TextBlock
{
Text = list.IsOrdered ? $"{index}." : "•", FontSize = 14, Margin = new Thickness(8, 0, 0, 0)
});
var content = new StackPanel { Spacing = 4 };
foreach(Block child in listItem) AppendBlock(content, child);
row.Children.Add(content);
panel.Children.Add(row);
index++;
}
return panel;
}
static Border BuildCodeBlock(LeafBlock code)
{
var textBlock = new TextBlock
{
Text = code.Lines.ToString(),
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
TextWrapping = TextWrapping.NoWrap
};
return new Border
{
Background = ThemeBrush("CardBackgroundFillColorDefaultBrush"),
Padding = new Thickness(8),
CornerRadius = new CornerRadius(4),
Child = new ScrollViewer
{
Content = textBlock,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
}
};
}
static Grid BuildTable(Table table)
{
var grid = new Grid();
for(int c = 0; c < table.ColumnDefinitions.Count; c++) grid.ColumnDefinitions.Add(new ColumnDefinition());
int rowIndex = 0;
foreach(TableRow row in table.OfType<TableRow>())
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
int columnIndex = 0;
foreach(TableCell cell in row.OfType<TableCell>())
{
var textBlock = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(4),
FontWeight = row.IsHeader ? FontWeights.SemiBold : FontWeights.Normal
};
foreach(Block cellBlock in cell)
if(cellBlock is ParagraphBlock { Inline: not null } paragraph)
AppendInlines(textBlock.Inlines, paragraph.Inline);
Grid.SetRow(textBlock, rowIndex);
Grid.SetColumn(textBlock, columnIndex);
grid.Children.Add(textBlock);
columnIndex++;
}
rowIndex++;
}
return grid;
}
static StackPanel BuildFootnotes(FootnoteGroup group)
{
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 8, 0, 0) };
panel.Children.Add(new Border
{
Height = 1,
Opacity = 0.4,
Margin = new Thickness(0, 0, 0, 4),
BorderThickness = new Thickness(1),
BorderBrush = ThemeBrush("TextFillColorSecondaryBrush")
});
foreach(Block block in group)
{
if(block is not Footnote footnote) continue;
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 };
row.Children.Add(new TextBlock
{
Text = $"{footnote.Order}.", FontSize = 12, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0)
});
var content = new StackPanel { Spacing = 4 };
foreach(Block child in footnote) AppendBlock(content, child);
row.Children.Add(content);
panel.Children.Add(row);
}
return panel;
}
static void AppendInlines(InlineCollection target, ContainerInline container)
{
foreach(Markdig.Syntax.Inlines.Inline inline in container) AppendInline(target, inline);
}
static void AppendInline(InlineCollection target, Markdig.Syntax.Inlines.Inline inline)
{
switch(inline)
{
case LiteralInline literal:
target.Add(new Run { Text = literal.Content.ToString() });
break;
case LineBreakInline:
target.Add(new LineBreak());
break;
case EmphasisInline emphasis:
var span = new Span();
if(emphasis.DelimiterCount >= 2) span.FontWeight = FontWeights.Bold;
else span.FontStyle = Windows.UI.Text.FontStyle.Italic;
AppendInlines(span.Inlines, emphasis);
target.Add(span);
break;
case CodeInline code:
target.Add(new Run { Text = code.Content, FontFamily = new FontFamily("Consolas") });
break;
case LinkInline { IsImage: true, Url: not null } image:
target.Add(new InlineUIContainer
{
Child = new Image
{
Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(image.Url)),
MaxWidth = 480,
MaxHeight = 360,
Stretch = Stretch.Uniform
}
});
break;
case LinkInline link:
var hyperlink = new Hyperlink();
AppendInlines(hyperlink.Inlines, link);
if(Uri.TryCreate(link.Url, UriKind.Absolute, out Uri? uri))
hyperlink.Click += async (_, _) => await Launcher.LaunchUriAsync(uri);
target.Add(hyperlink);
break;
case FootnoteLink footnoteLink:
target.Add(new Span
{
FontSize = 11,
Inlines = { new Run { Text = $"[{footnoteLink.Footnote?.Order}]" } }
});
break;
case ContainerInline containerInline:
AppendInlines(target, containerInline);
break;
}
}
static Brush ThemeBrush(string resourceKey) =>
(Brush)Application.Current.Resources[resourceKey];
}

View File

@@ -2,13 +2,7 @@
<UserControl x:Class="Marechai.App.Presentation.Views.MessageMarkdownView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<controls:MarkdownTextBlock x:Name="MarkdownBlock"
Margin="0"
IsTextSelectionEnabled="True"
ImageMaxWidth="720"
ImageMaxHeight="480"
LinkClicked="MarkdownBlock_OnLinkClicked" />
<Grid x:Name="RootGrid" />
</UserControl>

View File

@@ -1,10 +1,7 @@
#nullable enable
using System;
using CommunityToolkit.WinUI.UI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.System;
namespace Marechai.App.Presentation.Views;
@@ -27,16 +24,9 @@ public sealed partial class MessageMarkdownView : UserControl
static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if(d is MessageMarkdownView view)
view.MarkdownBlock.Text = e.NewValue as string ?? string.Empty;
}
if(d is not MessageMarkdownView view) return;
async void MarkdownBlock_OnLinkClicked(object sender, LinkClickedEventArgs e)
{
if(string.IsNullOrWhiteSpace(e.Link)) return;
if(Uri.TryCreate(e.Link, UriKind.Absolute, out Uri? uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeMailto))
await Launcher.LaunchUriAsync(uri);
view.RootGrid.Children.Clear();
view.RootGrid.Children.Add(MarkdownRenderer.Render(e.NewValue as string));
}
}

View File

@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
NavigationCacheMode="Required"
@@ -422,8 +423,7 @@
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=DescriptionSectionTitle}"
FontSize="20"
FontWeight="SemiBold" />
<WebView2 x:Name="DescriptionWebView"
MinHeight="200" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
</StackPanel>

View File

@@ -1,6 +1,5 @@
#nullable enable
using System.ComponentModel;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -12,56 +11,6 @@ public sealed partial class ProcessorDetailPage : Page
public ProcessorDetailPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is ProcessorDetailViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(ProcessorDetailViewModel.DescriptionHtml) &&
sender is ProcessorDetailViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
private void ComputersSearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)

View File

@@ -7,6 +7,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:components="using:Marechai.App.Presentation.Components"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:wctui="using:CommunityToolkit.WinUI.UI.Controls"
NavigationCacheMode="Disabled"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
@@ -234,8 +235,7 @@
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=DescriptionSectionTitle}"
FontSize="20"
FontWeight="SemiBold" />
<WebView2 x:Name="DescriptionWebView"
MinHeight="200" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
<!-- Family -->

View File

@@ -14,13 +14,6 @@ public sealed partial class SoftwareViewPage : Page
public SoftwareViewPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is SoftwareViewViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private async void OpenReviewDialog_Click(object sender, RoutedEventArgs e)
@@ -143,46 +136,4 @@ public sealed partial class SoftwareViewPage : Page
vm.ClearReviewFeedbackCommand.Execute(null);
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(SoftwareViewViewModel.DescriptionHtml) &&
sender is SoftwareViewViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
}

View File

@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
xmlns:local="using:Marechai.App.Presentation.Views"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
NavigationCacheMode="Required"
@@ -194,8 +195,7 @@
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=DescriptionSectionTitle}"
FontSize="20"
FontWeight="SemiBold" />
<WebView2 x:Name="DescriptionWebView"
MinHeight="200" />
<local:MessageMarkdownView Markdown="{Binding DescriptionMarkdown}" />
</StackPanel>
</StackPanel>

View File

@@ -1,6 +1,5 @@
#nullable enable
using System.ComponentModel;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -12,56 +11,6 @@ public sealed partial class SoundSynthDetailPage : Page
public SoundSynthDetailPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if(args.NewValue is SoundSynthDetailViewModel vm)
vm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if(e.PropertyName == nameof(SoundSynthDetailViewModel.DescriptionHtml) &&
sender is SoundSynthDetailViewModel vm &&
!string.IsNullOrWhiteSpace(vm.DescriptionHtml))
{
bool isDark = ActualTheme == ElementTheme.Dark;
var html = WrapInHtmlDocument(vm.DescriptionHtml, isDark);
DescriptionWebView.NavigateToString(html);
}
}
private static string WrapInHtmlDocument(string bodyHtml, bool isDark)
{
string textColor = isDark ? "#e0e0e0" : "#1a1a1a";
string bgColor = isDark ? "#1e1e1e" : "#ffffff";
string linkColor = isDark ? "#6cb4ee" : "#0063b1";
return $$"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 8px;
color: {{textColor}};
background: {{bgColor}};
}
a { color: {{linkColor}}; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>{{bodyHtml}}</body>
</html>
""";
}
private void ComputersSearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)