feat(PostIt): responsive two-column blogs layout, camera streamer (1.0.8-rc27)

- BlogsPage switches to a two-column layout when width >= height
  (post on the left, search/commands/list on the right), with a
  compact navigation bar; layout update deferred on the UI thread to
  avoid "Visual was invalidated during the render pass".
- Explicit DataContext on CommandBar items so overflowed buttons keep
  their bindings.
- Preliminary CameraStreamer service (DirectShowLib / V4L2 via LibVLC).
- Tests and architecture doc updated; CHANGELOG entry 1.0.8-rc27.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 13:47:33 +01:00
commit 08a3541749
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
8 changed files with 341 additions and 23 deletions

View file

@ -1,5 +1,28 @@
# Changelog
## [1.0.8-rc27] - unstable
### Added
* [PostIt] Service `CameraStreamer` (préliminaire) : détection des
périphériques de capture vidéo (DirectShow sous Windows via le paquet
`DirectShowLib`, `/dev/video*` sous Linux) et création d'un média
LibVLC de capture pour le périphérique choisi.
### Changed
* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de
la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et
liste à droite, avec séparateur vertical redimensionnable. La disposition
verticale revient en portrait sans perdre le brouillon ; la barre de
navigation est plus compacte en mode large et conserve le bouton Retour.
### Fixed
* [PostIt] Le changement de disposition de la page des blogs est différé
sur le thread UI, évitant l'exception « Visual was invalidated during
the render pass » au redimensionnement.
## [1.0.8-rc26] - unstable
### Added

View file

@ -178,6 +178,20 @@ et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Lorsque la largeur de la fenêtre atteint ou dépasse sa hauteur, le lecteur
ou l'éditeur passe à gauche (deux tiers de la largeur par défaut), et
l'en-tête, la recherche, les commandes et la liste passent à droite.
Le séparateur devient vertical. En portrait, la disposition verticale
revient sans recréer les contrôles ni perdre le brouillon.
Le seuil utilise les dimensions clientes de la fenêtre, indépendamment
de la hauteur occupée par la navigation et la bannière de session.
La page applique `NavigationPage.BarHeightOverride` à 36 en mode large ;
le `NavRoot` de `MainView` conserve ainsi le bouton Retour, sans changer
les autres pages. En portrait, la hauteur de navigation par défaut revient.
Les commandes ont un contexte de données explicite pour conserver leurs
liaisons, y compris lorsqu'elles passent dans le menu de débordement.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir

View file

@ -16,6 +16,7 @@
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
@ -37,4 +38,4 @@
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project>
</Project>

View file

@ -2,7 +2,9 @@ using System.Net;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Logging;
using Avalonia.Threading;
using Avalonia.VisualTree;
@ -95,6 +97,7 @@ public class BlogReaderTests
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.False(page.SaveButton.IsVisible);
@ -164,6 +167,141 @@ public class BlogReaderTests
}
}
[AvaloniaTheory]
[InlineData(360, 800, false)]
[InlineData(1000, 420, true)]
[InlineData(1000, 280, true)]
[InlineData(600, 600, true)]
[InlineData(599, 600, false)]
[InlineData(601, 600, true)]
public async Task Blog_layout_uses_columns_from_the_square_window_threshold(int width, int height, bool wide)
{
var vm = CreateViewModel();
await vm.InitializeAsync();
for (var i = 0; i < 40; i++)
vm.FilteredPosts.Add(Post($"Billet {i}"));
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
var page = new BlogsPage { DataContext = vm };
var main = new MainView();
var nav = main.FindControl<NavigationPage>("NavRoot")!;
await nav.PushAsync(new ContentPage());
await nav.PushAsync(page);
var window = new Window { Width = width, Height = height, Content = main };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var reader = page.FindControl<Grid>("PostReader")!;
var list = page.FindControl<Border>("PostsListBorder")!;
var splitter = page.FindControl<GridSplitter>("PostsSplitter")!;
Assert.Equal(wide ? GridResizeDirection.Columns : GridResizeDirection.Rows, splitter.ResizeDirection);
Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
Assert.True(nav.IsBackButtonEffectivelyVisible);
Assert.True(reader.Bounds.Width > 0 && reader.Bounds.Height > 0);
Assert.True(list.Bounds.Width > 0 && list.Bounds.Height > 0);
if (wide)
{
Assert.True(reader.Bounds.Right <= list.Bounds.Left);
Assert.Equal(2, Grid.GetColumn(page.BlogsHeaderPanel));
Assert.Equal(2, Grid.GetColumn(page.BlogsCommandBar));
Assert.Equal(reader.Margin.Top, reader.Bounds.Top);
Assert.Equal(page.StatusBar.Bounds.Top - reader.Margin.Bottom, reader.Bounds.Bottom);
Assert.Equal(36, page.TranslatePoint(new Point(), nav)!.Value.Y);
}
else
{
Assert.True(list.Bounds.Bottom <= reader.Bounds.Top);
Assert.Equal(0, Grid.GetColumn(page.BlogsHeaderPanel));
Assert.Equal(0, Grid.GetColumn(page.BlogsCommandBar));
}
var articleScroll = page.FindControl<MarkdownScrollViewer>("ArticleReader")!
.GetVisualDescendants().OfType<ScrollViewer>().Single();
Assert.InRange(articleScroll.Viewport.Height, 1, window.ClientSize.Height);
Assert.True(articleScroll.Extent.Height > articleScroll.Viewport.Height);
var listScroll = page.PostsListBox.GetVisualDescendants().OfType<ScrollViewer>().Single();
Assert.InRange(listScroll.Viewport.Height, 1, window.ClientSize.Height);
Assert.True(listScroll.Extent.Height > listScroll.Viewport.Height);
var originalReaderSize = reader.Bounds.Size;
splitter.Focus();
splitter.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = wide ? Key.Right : Key.Down
});
window.UpdateLayout();
if (wide)
Assert.True(reader.Bounds.Width > originalReaderSize.Width);
else
Assert.True(reader.Bounds.Height < originalReaderSize.Height);
page.BlogsCommandBar.IsOpen = true;
Dispatcher.UIThread.RunJobs();
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.Same(vm, page.SaveButton.DataContext);
Assert.False(page.SaveButton.IsVisible);
Assert.True(page.EditPostButton.IsVisible);
vm.EditPostCommand.Execute(null);
Dispatcher.UIThread.RunJobs();
Assert.True(page.SaveButton.IsVisible);
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
Assert.False(page.EditPostButton.IsVisible);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Resizing_between_portrait_and_landscape_preserves_the_editor_and_draft()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
vm.SelectedPost = Post();
vm.EditPostCommand.Execute(null);
vm.DraftArticle = "Brouillon conservé pendant le redimensionnement.";
var document = vm.DraftArticleDocument;
var page = new BlogsPage { DataContext = vm };
var editor = page.FindControl<Grid>("PostEditor")!;
var nav = new NavigationPage();
await nav.PushAsync(page);
var window = new Window { Width = 360, Height = 800, Content = nav };
try
{
window.Show();
foreach (var size in new[] { new Size(360, 800), new Size(1000, 420), new Size(600, 600), new Size(599, 600), new Size(360, 800) })
{
window.Width = size.Width;
window.Height = size.Height;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
var wide = size.Width >= size.Height;
Assert.Equal(wide ? 0 : 4, Grid.GetRow(editor));
Assert.Equal(wide ? 3 : 1, Grid.GetRowSpan(editor));
Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
Assert.Same(editor, page.FindControl<Grid>("PostEditor"));
Assert.True(editor.IsVisible);
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.True(vm.IsEditing);
Assert.True(vm.IsDraftModified);
Assert.Equal("Brouillon conservé pendant le redimensionnement.", vm.DraftArticle);
Assert.Same(document, vm.DraftArticleDocument);
Assert.Equal(42, vm.SelectedPost!.Id);
Assert.True(editor.Bounds.Width > 0 && editor.Bounds.Height > 0);
}
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
{

View file

@ -32,6 +32,7 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="DirectShowLib" />
<PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" />

View file

@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
// Note : Si vous compilez sous Linux, DirectShowLib n'est pas disponible.
// Pour un projet multiplateforme propre, vous pouvez utiliser des directives de précompilation (#if WINDOWS)
// ou charger la détection DirectShow dans une assembly séparée.
using DirectShowLib;
public class CameraStreamer
{
private readonly LibVLC _libVLC;
public CameraStreamer(LibVLC libVLC)
{
_libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC));
}
/// <summary>
/// Récupère la liste des périphériques de capture disponibles sur la machine.
/// Retourne un dictionnaire : Clé = Nom lisible, Valeur = Identifiant/Chemin système
/// </summary>
public Dictionary<string, string> GetCaptureDevices()
{
var devices = new Dictionary<string, string>();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Détection Windows via DirectShow
DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
foreach (var device in capDevices)
{
// Pour Windows, VLC utilise le nom de l'appareil
devices.Add(device.Name, device.Name);
}
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// Détection Linux via V4L2 (Video4Linux2)
if (Directory.Exists("/dev"))
{
var videoFiles = Directory.GetFiles("/dev", "video*");
foreach (var path in videoFiles)
{
// Exemple : Clé = "Caméra (/dev/video0)", Valeur = "/dev/video0"
devices.Add($"Caméra ({path})", path);
}
}
}
else
{
throw new PlatformNotSupportedException("OS non supporté pour la capture automatique dans cet exemple.");
}
return devices;
}
/// <summary>
/// Crée un objet Media configuré pour le périphérique sélectionné.
/// </summary>
/// <param name="deviceValue">Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)</param>
public Media CreateCaptureMedia(string deviceValue)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Configuration pour DirectShow (Windows)
var media = new Media(_libVLC, "dshow://", FromType.FromLocation);
media.AddOption($":dshow-vdev={deviceValue}");
// Options optionnelles (Exemple : Forcer la résolution ou le framerate)
// media.AddOption(":dshow-size=1280x720");
// media.AddOption(":dshow-fps=30");
return media;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// Configuration pour V4L2 (Linux)
var media = new Media(_libVLC, "v4l2://", FromType.FromLocation);
media.AddOption($":v4l2-vdev={deviceValue}");
// Options optionnelles pour Linux
// media.AddOption(":v4l2-width=1280");
// media.AddOption(":v4l2-height=720");
return media;
}
throw new PlatformNotSupportedException();
}
}

View file

@ -1,5 +1,6 @@
<ContentPage
x:Class="PostIt.Views.Blogs.BlogsPage"
x:Name="BlogsPageRoot"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@ -23,8 +24,8 @@
<Setter Property="FontWeight" Value="Bold" />
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*,Auto"
<Grid x:Name="MainGrid"
RowDefinitions="Auto,Auto,*,4,2*,Auto"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
@ -45,18 +46,19 @@
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
IsVisible="{Binding IsEditing}" Label="Lire" />
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="EditPostButton" Command="{Binding EditPostCommand}"
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
IsVisible="{Binding !IsEditing}" Label="Modifier" />
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="SaveButton"
IsVisible="{Binding IsEditing}"
Command="{Binding SaveCommand}"
@ -67,12 +69,13 @@
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="AddAttachmentButton"
IsVisible="{Binding IsEditing}"
Click="AddAttachment_Click"
@ -83,6 +86,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="ManageAclButton"
IsVisible="{Binding IsEditing}"
Command="{Binding ManageAclAsync}"
@ -92,6 +96,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Label="Mes cercles">
@ -100,6 +105,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
IsVisible="{Binding IsEditing}"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
@ -108,12 +114,13 @@
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton DataContext="{Binding #BlogsPageRoot.DataContext}" IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="OpenSignatureDevButton"
IsVisible="{Binding IsEditing}"
Command="{Binding OpenSignatureDevAsync}"
@ -125,14 +132,9 @@
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="4" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
<Border x:Name="PostsListBorder"
Grid.Row="2"
Margin="12"
Padding="8"
BorderBrush="Gray"
BorderThickness="1">
@ -178,14 +180,16 @@
</ListBox>
</Border>
<GridSplitter
Grid.Row="1"
<GridSplitter x:Name="PostsSplitter"
Grid.Row="3"
Margin="12,0"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid x:Name="PostEditor"
IsVisible="{Binding IsEditing}"
Grid.Row="2"
Grid.Row="4"
Margin="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RowSpacing="10">
@ -237,7 +241,7 @@
</Border>
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
<Grid x:Name="PostReader" Grid.Row="4" Margin="12" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
@ -263,7 +267,6 @@
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</Grid>
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
<postitControls:StatusBar Grid.Row="5" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -1,6 +1,8 @@
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
@ -13,14 +15,57 @@ public partial class BlogsPage : ContentPage
{
private static readonly RelayCommand<string?> DisabledLinkCommand =
new(static _ => { }, static _ => false);
private bool? _isWideLayout;
public BlogsPage()
{
InitializeComponent();
SizeChanged += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
UpdateResponsiveLayout();
});
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
UpdateHyperlinkCommand();
}
private void UpdateResponsiveLayout()
{
var size = TopLevel.GetTopLevel(this)?.ClientSize ?? Bounds.Size;
if (size.Width <= 0 || size.Height <= 0)
return;
// Use the window size so compacting the navigation bar cannot change the orientation.
var isWide = size.Width >= size.Height;
if (_isWideLayout == isWide)
return;
_isWideLayout = isWide;
MainGrid.RowDefinitions = new RowDefinitions(isWide ? "Auto,Auto,*,Auto" : "Auto,Auto,*,4,2*,Auto");
MainGrid.ColumnDefinitions = new ColumnDefinitions(isWide ? "2*,4,*" : "*");
var sidebarColumn = isWide ? 2 : 0;
SetArea(BlogsHeaderPanel, 0, sidebarColumn);
SetArea(BlogsCommandBar, 1, sidebarColumn);
SetArea(PostsListBorder, 2, sidebarColumn);
SetArea(PostsSplitter, isWide ? 0 : 3, isWide ? 1 : 0, isWide ? 3 : 1);
SetArea(PostReader, isWide ? 0 : 4, 0, isWide ? 3 : 1);
SetArea(PostEditor, isWide ? 0 : 4, 0, isWide ? 3 : 1);
SetArea(StatusBar, isWide ? 3 : 5, 0, columnSpan: isWide ? 3 : 1);
PostsSplitter.ResizeDirection = isWide ? GridResizeDirection.Columns : GridResizeDirection.Rows;
PostsSplitter.HorizontalAlignment = isWide ? HorizontalAlignment.Center : HorizontalAlignment.Stretch;
PostsSplitter.VerticalAlignment = isWide ? VerticalAlignment.Stretch : VerticalAlignment.Center;
PostsSplitter.Margin = isWide ? new Thickness(0, 12) : new Thickness(12, 0);
NavigationPage.SetBarHeightOverride(this, isWide ? 36 : null);
}
private static void SetArea(Control control, int row, int column, int rowSpan = 1, int columnSpan = 1)
{
Grid.SetRow(control, row);
Grid.SetColumn(control, column);
Grid.SetRowSpan(control, rowSpan);
Grid.SetColumnSpan(control, columnSpan);
}
private void UpdateHyperlinkCommand()
{
if (ArticleReader.Engine is IMarkdownEngine2 engine)