Compare commits

...
Author SHA1 Message Date
ebdf51f468
fix(postit): restore Markdown editor and preview scrolling
All checks were successful
Forgejo Release / release (push) Successful in 7m38s
Dotnet build and test / build (pull_request) Successful in 8m25s
Constrain the blog page layout, separate list and article scrolling, and remove content height limits and nested editor scrolling. Add six headless regression cases and update the changelog and layout documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 18:28:21 +01:00
cd7351a783
feat(postit): rejoin chat room automatically after reconnect
Also drop unused usings in BlogsViewModel.
2026-10-09 17:55:03 +01:00
95e8f251d2
test(postit): verify published badge and publication state notifications
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:47:01 +01:00
e7721bc3ab
feat(postit): show a live published badge beside blog post titles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:46:32 +01:00
10 changed files with 308 additions and 74 deletions

View file

@ -1,10 +1,33 @@
# Changelog # Changelog
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable ## [1.0.8-rc23] - unstable
### Added ### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification * [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel, d'avatar, confirmation e-mail, préférence de courriel mensuel,

View file

@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour > à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`. > `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
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.
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
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

@ -0,0 +1,143 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -1,12 +1,47 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views.Blogs;
namespace PostIt.Tests; namespace PostIt.Tests;
public class PostItViewModelTests public class PostItViewModelTests
{ {
[AvaloniaFact]
public void Published_badge_tracks_post_publication_state()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact] [Fact]
public void SearchCommand_filters_posts_by_title_article_or_author() public void SearchCommand_filters_posts_by_title_article_or_author()
@ -61,11 +96,22 @@ public class PostItViewModelTests
var viewModel = new BlogsViewModel(blog); var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true); await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue); Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished); Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
} }
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>

View file

@ -7,20 +7,13 @@
x:Name="Root" x:Name="Root"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="260"> <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Border <Border
BorderBrush="LightGray" BorderBrush="LightGray"
BorderThickness="1" BorderThickness="1"
CornerRadius="5" CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}" IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0" Padding="0">
MinHeight="260">
<ScrollViewer
BringIntoViewOnFocusChange="False"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<AvaloniaEdit:TextEditor <AvaloniaEdit:TextEditor
x:Name="SourceEditor" x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}" Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
@ -35,7 +28,6 @@
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True" TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" /> TextInputOptions.ReturnKeyType="Default" />
</ScrollViewer>
</Border> </Border>
<Border <Border
@ -43,18 +35,21 @@
BorderThickness="1" BorderThickness="1"
CornerRadius="5" CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}" IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8" Padding="8">
MinHeight="260">
<ScrollViewer <ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False" BringIntoViewOnFocusChange="False"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer <md:MarkdownViewer
x:Name="PreviewViewer" x:Name="PreviewViewer"
MarkdownText="{Binding MarkdownText, ElementName=Root}" HorizontalContentAlignment="Stretch"
ClipToBounds="False" /> VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer> </ScrollViewer>
</Border> </Border>
</Grid> </Grid>

View file

@ -38,8 +38,6 @@ public partial class MarkdownEditorControl : UserControl
{ {
InitializeComponent(); InitializeComponent();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
SizeChanged += OnSizeChanged;
UpdateEditorLimits();
} }
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@ -62,27 +60,4 @@ public partial class MarkdownEditorControl : UserControl
} }
} }
} }
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
UpdateEditorLimits();
}
private void UpdateEditorLimits()
{
var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
visibleHeight = Math.Max(visibleHeight, 80);
if (SourceEditor is not null)
{
SourceEditor.MaxHeight = visibleHeight;
SourceEditor.Height = double.NaN;
}
if (PreviewViewer is not null)
{
PreviewViewer.MaxHeight = visibleHeight;
PreviewViewer.Height = double.NaN;
}
}
} }

View file

@ -10,9 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using PostIt.Helpers;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat
}); });
return Task.CompletedTask; return Task.CompletedTask;
}; };
_connection.Reconnected += _ => _connection.Reconnected += async _ =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
@ -254,7 +254,11 @@ namespace PostIt.ViewModels.Chat
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info, Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
}); });
return Task.CompletedTask; // La reconnexion crée une nouvelle ConnectionId côté
// serveur : l'appartenance aux salons est perdue, il
// faut rejoindre à nouveau le salon courant.
if (!string.IsNullOrWhiteSpace(CurrentRoom))
await JoinRoomAsync();
}; };
_connection.Closed += error => _connection.Closed += error =>
{ {

View file

@ -17,14 +17,11 @@
<Design.DataContext> <Design.DataContext>
<vm:BlogsViewModel /> <vm:BlogsViewModel />
</Design.DataContext> </Design.DataContext>
<ScrollViewer <Grid
RowDefinitions="Auto,Auto,*"
Margin="5,5,10,5" Margin="5,5,10,5"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" VerticalAlignment="Stretch">
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
BringIntoViewOnFocusChange="False">
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<WrapPanel x:Name="BlogsHeaderPanel" <WrapPanel x:Name="BlogsHeaderPanel"
Margin="5" Margin="5"
@ -39,6 +36,7 @@
Text="{Binding SearchText, Mode=TwoWay}" /> Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel> </WrapPanel>
<CommandBar x:Name="BlogsCommandBar" <CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True"> HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands> <CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh"> <CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
@ -107,16 +105,14 @@
</CommandBar.PrimaryCommands> </CommandBar.PrimaryCommands>
</CommandBar> </CommandBar>
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid" MinHeight="500"> <Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="4" />
<RowDefinition Height="Auto" /> <RowDefinition Height="2*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border <Border
Grid.Row="1" Grid.Row="0"
Padding="8" Padding="8"
BorderBrush="Gray" BorderBrush="Gray"
BorderThickness="1"> BorderThickness="1">
@ -126,7 +122,27 @@
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" /> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding Title}" />
<Border
x:Name="PublishedBadge"
VerticalAlignment="Center"
Padding="6,2"
Background="#F2F7F2"
BorderBrush="#2F6D2F"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsPublished}">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="#2F6D2F"
Text="Publié" />
</Border>
</StackPanel>
<TextBlock <TextBlock
FontSize="10" FontSize="10"
Foreground="Gray" Foreground="Gray"
@ -141,6 +157,11 @@
</ListBox> </ListBox>
</Border> </Border>
<GridSplitter
Grid.Row="1"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid <Grid
Grid.Row="2" Grid.Row="2"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
@ -151,6 +172,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@ -197,6 +219,5 @@
</Grid> </Grid>
</Grid> </Grid>
</StackPanel> </Grid>
</ScrollViewer>
</ContentPage> </ContentPage>

View file

@ -1,12 +1,16 @@
#nullable enable annotations #nullable enable annotations
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.ComponentModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost public class BlogPostDto : IBlogPost, INotifyPropertyChanged
{ {
public event PropertyChangedEventHandler? PropertyChanged;
private bool _isPublished;
public string AuthorId { get; set; } public string AuthorId { get; set; }
public BlogPostAuthorDto? Author { get; set; } public BlogPostAuthorDto? Author { get; set; }
@ -30,7 +34,18 @@ public class BlogPostDto : IBlogPost
/// follow-up request. Toggled via /// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>. /// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary> /// </summary>
public bool IsPublished { get; set; } public bool IsPublished
{
get => _isPublished;
set
{
if (_isPublished == value)
return;
_isPublished = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished)));
}
}
public virtual bool AuthorizeCircle(long circleId) public virtual bool AuthorizeCircle(long circleId)
{ {