New MarkdownView UserControl renders a markdown preview. Details island gains an editable Description section with edit/preview toggle, collapsible header, copy-to-clipboard, and debounced auto-save (400ms). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
7.0 KiB
C#
232 lines
7.0 KiB
C#
using System.Text.RegularExpressions;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Documents;
|
|
using Avalonia.Layout;
|
|
using Avalonia.Markup.Xaml;
|
|
using Avalonia.Media;
|
|
|
|
namespace ClaudeDo.Ui.Views.Controls;
|
|
|
|
public partial class MarkdownView : UserControl
|
|
{
|
|
public static readonly StyledProperty<string?> MarkdownProperty =
|
|
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
|
|
|
|
public string? Markdown
|
|
{
|
|
get => GetValue(MarkdownProperty);
|
|
set => SetValue(MarkdownProperty, value);
|
|
}
|
|
|
|
private StackPanel? _host;
|
|
|
|
public MarkdownView()
|
|
{
|
|
InitializeComponent();
|
|
_host = this.FindControl<StackPanel>("Host");
|
|
}
|
|
|
|
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
|
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
{
|
|
base.OnPropertyChanged(change);
|
|
if (change.Property == MarkdownProperty)
|
|
Render(change.GetNewValue<string?>());
|
|
}
|
|
|
|
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
|
|
|
|
private void Render(string? md)
|
|
{
|
|
_host ??= this.FindControl<StackPanel>("Host");
|
|
if (_host is null) return;
|
|
_host.Children.Clear();
|
|
|
|
if (string.IsNullOrWhiteSpace(md))
|
|
return;
|
|
|
|
var lines = md.Replace("\r\n", "\n").Split('\n');
|
|
int i = 0;
|
|
while (i < lines.Length)
|
|
{
|
|
var line = lines[i];
|
|
if (string.IsNullOrWhiteSpace(line)) { i++; continue; }
|
|
|
|
if (line.StartsWith("### "))
|
|
{
|
|
_host.Children.Add(Heading(line[4..], 13));
|
|
i++;
|
|
continue;
|
|
}
|
|
if (line.StartsWith("## "))
|
|
{
|
|
_host.Children.Add(Heading(line[3..], 15));
|
|
i++;
|
|
continue;
|
|
}
|
|
if (line.StartsWith("# "))
|
|
{
|
|
_host.Children.Add(Heading(line[2..], 17));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (line.StartsWith("- ") || line.StartsWith("* "))
|
|
{
|
|
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
|
|
{
|
|
_host.Children.Add(Bullet(lines[i][2..]));
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
var num = NumberedItem.Match(line);
|
|
if (num.Success)
|
|
{
|
|
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
|
|
{
|
|
var m = NumberedItem.Match(lines[i]);
|
|
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
var paragraph = new System.Text.StringBuilder();
|
|
while (i < lines.Length
|
|
&& !string.IsNullOrWhiteSpace(lines[i])
|
|
&& !IsBlockStart(lines[i]))
|
|
{
|
|
if (paragraph.Length > 0) paragraph.Append(' ');
|
|
paragraph.Append(lines[i].Trim());
|
|
i++;
|
|
}
|
|
_host.Children.Add(Paragraph(paragraph.ToString()));
|
|
}
|
|
}
|
|
|
|
private static bool IsBlockStart(string line) =>
|
|
line.StartsWith("# ") || line.StartsWith("## ") || line.StartsWith("### ")
|
|
|| line.StartsWith("- ") || line.StartsWith("* ")
|
|
|| NumberedItem.IsMatch(line);
|
|
|
|
private static Control Heading(string text, double size)
|
|
{
|
|
var tb = new SelectableTextBlock
|
|
{
|
|
FontSize = size,
|
|
FontWeight = FontWeight.SemiBold,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
Margin = new Thickness(0, 4, 0, 2),
|
|
};
|
|
AppendInlines(tb.Inlines!, text);
|
|
return tb;
|
|
}
|
|
|
|
private static Control Paragraph(string text)
|
|
{
|
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
|
AppendInlines(tb.Inlines!, text);
|
|
return tb;
|
|
}
|
|
|
|
private static Control Bullet(string text)
|
|
{
|
|
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("14,*") };
|
|
var dot = new TextBlock
|
|
{
|
|
Text = "•",
|
|
VerticalAlignment = VerticalAlignment.Top,
|
|
Margin = new Thickness(0, 0, 4, 0),
|
|
};
|
|
Grid.SetColumn(dot, 0);
|
|
grid.Children.Add(dot);
|
|
|
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
|
AppendInlines(tb.Inlines!, text);
|
|
Grid.SetColumn(tb, 1);
|
|
grid.Children.Add(tb);
|
|
return grid;
|
|
}
|
|
|
|
private static Control Numbered(string num, string text)
|
|
{
|
|
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("20,*") };
|
|
var lbl = new TextBlock
|
|
{
|
|
Text = $"{num}.",
|
|
VerticalAlignment = VerticalAlignment.Top,
|
|
Margin = new Thickness(0, 0, 4, 0),
|
|
};
|
|
Grid.SetColumn(lbl, 0);
|
|
grid.Children.Add(lbl);
|
|
|
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
|
AppendInlines(tb.Inlines!, text);
|
|
Grid.SetColumn(tb, 1);
|
|
grid.Children.Add(tb);
|
|
return grid;
|
|
}
|
|
|
|
private static void AppendInlines(InlineCollection inlines, string text)
|
|
{
|
|
int i = 0;
|
|
var plain = new System.Text.StringBuilder();
|
|
|
|
void FlushPlain()
|
|
{
|
|
if (plain.Length == 0) return;
|
|
inlines.Add(new Run(plain.ToString()));
|
|
plain.Clear();
|
|
}
|
|
|
|
while (i < text.Length)
|
|
{
|
|
if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*')
|
|
{
|
|
int close = text.IndexOf("**", i + 2, System.StringComparison.Ordinal);
|
|
if (close > i + 2)
|
|
{
|
|
FlushPlain();
|
|
inlines.Add(new Run(text[(i + 2)..close]) { FontWeight = FontWeight.Bold });
|
|
i = close + 2;
|
|
continue;
|
|
}
|
|
}
|
|
if (text[i] == '*')
|
|
{
|
|
int close = text.IndexOf('*', i + 1);
|
|
if (close > i + 1)
|
|
{
|
|
FlushPlain();
|
|
inlines.Add(new Run(text[(i + 1)..close]) { FontStyle = FontStyle.Italic });
|
|
i = close + 1;
|
|
continue;
|
|
}
|
|
}
|
|
if (text[i] == '`')
|
|
{
|
|
int close = text.IndexOf('`', i + 1);
|
|
if (close > i + 1)
|
|
{
|
|
FlushPlain();
|
|
inlines.Add(new Run(text[(i + 1)..close])
|
|
{
|
|
FontFamily = new FontFamily("Consolas,Menlo,monospace"),
|
|
Background = new SolidColorBrush(Color.FromArgb(60, 127, 127, 127)),
|
|
});
|
|
i = close + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
plain.Append(text[i]);
|
|
i++;
|
|
}
|
|
FlushPlain();
|
|
}
|
|
}
|