10
\$\begingroup\$

I wanted to display a markdown document in WPF sensibly without relying on an HTML rendering system. I wrote a control based on the RichTextBox, and used Microsoft's markdown parser. What do you think?

XAML:

<RichTextBox x:Class="MarkdownViewer.MarkdownBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:MarkdownViewer"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             x:Name="Content">
</RichTextBox>

C# backend for the XAML file:

public partial class MarkdownBox : RichTextBox
{
    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(nameof(Text), typeof(string), typeof(MarkdownBox), new UIPropertyMetadata(default(string), PropertyChangedCallback));

    private static void PropertyChangedCallback(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        if (source is MarkdownBox control)
        {
            var newValue = (string)args.NewValue;
            switch (args.Property.Name)
            {
                case nameof(Text):
                    control.Text = newValue;
                    break;
            }
        }
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set
        {
            var old = GetValue(TextProperty);
            SetValue(TextProperty, value);
            OnPropertyChanged(new DependencyPropertyChangedEventArgs(TextProperty, old, value));

            SetTextboxContent();
        }
    }

    public MarkdownBox()
    {
        InitializeComponent();
    }

    private void Hlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
    {
        Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri));
        e.Handled = true;
    }

    private void SetTextboxContent()
    {
        Content.Document.Blocks.Clear();

        var doc = new MarkdownDocument();
        doc.Parse(Text ?? string.Empty);

        Content.Document.Blocks.AddRange(GetBlocks(doc.Blocks));
    }

    private IEnumerable<Block> GetBlocks(IList<MarkdownBlock> blocks)
    {
        foreach (var block in blocks)
        {
            switch (block)
            {
                case HeaderBlock header:
                    yield return GetHeaderBlock(header);
                    break;
                case ParagraphBlock paragraph:
                    yield return GetParagraphBlock(paragraph);
                    break;
                case ListBlock list:
                    yield return GetListBlock(list);
                    break;
                case CodeBlock code:
                    yield return GetCodeBlock(code);
                    break;
                case QuoteBlock quote:
                    yield return GetQuoteBlock(quote);
                    break;
                case HorizontalRuleBlock rule:
                    yield return GetRuleBlock(rule);
                    break;
                case TableBlock table:
                    yield return GetTableBlock(table);
                    break;
                default:
                    throw new NotImplementedException();
            }
        }
    }

    private Block GetHeaderBlock(HeaderBlock header)
    {
        var headerLevels = new Dictionary<int, double>
        {
            [1] = 28,
            [2] = 21,
            [3] = 16.3833,
            [4] = 14,
            [5] = 11.6167,
            [6] = 9.38333,
        };

        var content = header.Inlines.Select(GetInline);
        var span = new Span();
        span.Inlines.AddRange(content);

        var labelElement = new Label
        {
            Content = span,
            FontSize = headerLevels[header.HeaderLevel]
        };
        var blockElement = new BlockUIContainer(labelElement);
        return blockElement;
    }

    private Block GetParagraphBlock(ParagraphBlock paragraph)
    {
        var paragraphElement = new Paragraph();
        paragraphElement.Inlines.AddRange(paragraph.Inlines.Select(GetInline));
        return paragraphElement;
    }

    private Block GetListBlock(ListBlock list)
    {
        var listElement = new List
        {
            MarkerStyle = list.Style == ListStyle.Bulleted ? TextMarkerStyle.Disc : TextMarkerStyle.Decimal
        };
        foreach (var item in list.Items)
        {
            var listItemElement = new ListItem();
            listItemElement.Blocks.AddRange(GetBlocks(item.Blocks));
            listElement.ListItems.Add(listItemElement);
        }

        return listElement;
    }

    private Block GetCodeBlock(CodeBlock code)
    {
        var typeConverter = new HighlightingDefinitionTypeConverter();
        var avalon = new TextEditor
        {
            Text = code.Text,
            SyntaxHighlighting = (IHighlightingDefinition)typeConverter.ConvertFrom("C#"),
            FontFamily = new FontFamily("Consolas"),
            FontSize = 12,
            Padding = new Thickness(10),
            BorderBrush = Brushes.LightGray,
            BorderThickness = new Thickness(1),
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            IsReadOnly = true,
            ShowLineNumbers = true,
            MaxHeight = 250
        };

        return new BlockUIContainer(avalon);
    }

    private Block GetQuoteBlock(QuoteBlock quote)
    {
        var sectionElement = new Section
        {
            Background = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xDC)),
            BorderBrush = new SolidColorBrush(Color.FromRgb(0xff, 0xeb, 0x8e)),
            BorderThickness = new Thickness(2, 0, 0, 0),
            Padding = new Thickness(5)
        };
        var quoteBlocks = GetBlocks(quote.Blocks).ToList();
        for (var i = 0; i < quoteBlocks.Count; i++)
        {
            var item = quoteBlocks[i];
            item.Padding = new Thickness(5, 0, 5, 0);
            item.Margin = new Thickness(0);
            sectionElement.Blocks.Add(item);
        }

        return sectionElement;
    }

    private Block GetRuleBlock(HorizontalRuleBlock rule)
    {
        var line = new Line
        {
            Stretch = Stretch.Fill,
            Stroke = Brushes.DarkGray,
            X2 = 1
        };
        return new Paragraph(new InlineUIContainer(line));
    }

    private Block GetTableBlock(TableBlock table)
    {
        var alignments = new Dictionary<ColumnAlignment, TextAlignment>
        {
            [ColumnAlignment.Center] = TextAlignment.Center,
            [ColumnAlignment.Left] = TextAlignment.Left,
            [ColumnAlignment.Right] = TextAlignment.Right,
            [ColumnAlignment.Unspecified] = TextAlignment.Justify
        };

        var tableElement = new Table
        {
            BorderThickness = new Thickness(0, 0, 1, 1),
            BorderBrush = new SolidColorBrush(Color.FromRgb(0xdf, 0xe2, 0xe5)),
            CellSpacing = 0
        };
        var tableRowGroup = new TableRowGroup();
        for (int rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++)
        {
            var row = table.Rows[rowIndex];
            var tableRow = new TableRow();

            if (rowIndex % 2 == 0 && rowIndex != 0)
            {
                tableRow.Background = new SolidColorBrush(Color.FromRgb(0xf6, 0xf8, 0xfa));
            }

            for (int cellIndex = 0; cellIndex < row.Cells.Count; cellIndex++)
            {
                var cell = row.Cells[cellIndex];

                var cellContent = new Paragraph();
                cellContent.Inlines.AddRange(cell.Inlines.Select(GetInline));

                var tableCell = new TableCell
                {
                    TextAlignment = alignments[table.ColumnDefinitions[cellIndex].Alignment],
                    BorderBrush = new SolidColorBrush(Color.FromRgb(0xdf, 0xe2, 0xe5)),
                    BorderThickness = new Thickness(1, 1, 0, 0),
                    Padding = new Thickness(13, 6, 13, 6)
                };
                tableCell.Blocks.Add(cellContent);

                if (rowIndex == 0)
                {
                    tableCell.FontWeight = FontWeights.Bold;
                }

                tableRow.Cells.Add(tableCell);
            }

            tableRowGroup.Rows.Add(tableRow);
        }
        tableElement.RowGroups.Add(tableRowGroup);

        return tableElement;
    }

    private Inline GetInline(MarkdownInline element)
    {
        switch (element)
        {
            case BoldTextInline bold:
                return GetBoldInline(bold);
            case TextRunInline text:
                return GetTextRunInline(text);
            case ItalicTextInline italic:
                return GetItalicInline(italic);
            case StrikethroughTextInline strikethrough:
                return GetStrikethroughInline(strikethrough);
            case CodeInline code:
                return GetCodeInline(code);
            case MarkdownLinkInline markdownLink:
                return GetMarkdownLinkInline(markdownLink);
            case HyperlinkInline hyperlink:
                return GetHyperlinkInline(hyperlink);
            case ImageInline image:
                return GetImageInline(image);
            case SubscriptTextInline subscript:
                return GetSubscriptInline(subscript);
            case SuperscriptTextInline superscript:
                return GetSuperscriptInline(superscript);
            default:
                throw new NotImplementedException();
        }
    }

    private Inline GetBoldInline(BoldTextInline bold)
    {
        var boldElement = new Bold();
        foreach (var inline in bold.Inlines)
        {
            boldElement.Inlines.Add(GetInline(inline));
        }
        return boldElement;
    }

    private static Inline GetTextRunInline(TextRunInline text)
    {
        return new Run(text.ToString());
    }

    private Inline GetItalicInline(ItalicTextInline italic)
    {
        var italicElement = new Italic();
        foreach (var inline in italic.Inlines)
        {
            italicElement.Inlines.Add(GetInline(inline));
        }
        return italicElement;
    }

    private Inline GetStrikethroughInline(StrikethroughTextInline strikethrough)
    {
        var strikethroughElement = new Span();
        strikethroughElement.TextDecorations.Add(TextDecorations.Strikethrough);
        foreach (var inline in strikethrough.Inlines)
        {
            strikethroughElement.Inlines.Add(GetInline(inline));
        }
        return strikethroughElement;
    }

    private static Inline GetCodeInline(CodeInline code)
    {
        return new Run(code.Text)
        {
            Background = new SolidColorBrush(Color.FromRgb(0xef, 0xf0, 0xf1))
        };
    }

    private Inline GetMarkdownLinkInline(MarkdownLinkInline markdownLink)
    {
        var markdownLinkElement = new Hyperlink();
        markdownLinkElement.Inlines.AddRange(markdownLink.Inlines.Select(GetInline));
        markdownLinkElement.NavigateUri = new Uri(markdownLink.Url);
        markdownLinkElement.ToolTip = markdownLink.Tooltip;
        markdownLinkElement.RequestNavigate += Hlink_RequestNavigate;
        return markdownLinkElement;
    }

    private Inline GetHyperlinkInline(HyperlinkInline hyperlink)
    {
        var hyperlinkElement = new Hyperlink();
        hyperlinkElement.Inlines.Add(hyperlink.Text);
        hyperlinkElement.NavigateUri = new Uri(hyperlink.Url);
        hyperlinkElement.RequestNavigate += Hlink_RequestNavigate;
        return hyperlinkElement;
    }

    private static Inline GetImageInline(ImageInline image)
    {
        var uri = new Uri(image.RenderUrl);
        var bitmap = new BitmapImage(uri);
        var imageElement = new Image
        {
            Source = bitmap,
            Height = image.ImageHeight == 0 ? double.NaN : image.ImageHeight,
            Width = image.ImageWidth == 0 ? double.NaN : image.ImageWidth,
            ToolTip = image.Tooltip
        };
        return new InlineUIContainer(imageElement);
    }

    private Inline GetSubscriptInline(SubscriptTextInline subscript)
    {
        var subscriptElement = new Span();
        subscriptElement.Typography.Variants = FontVariants.Subscript;
        foreach (var inline in subscript.Inlines)
        {
            subscriptElement.Inlines.Add(GetInline(inline));
        }
        return subscriptElement;
    }

    private Inline GetSuperscriptInline(SuperscriptTextInline superscript)
    {
        var superscriptElement = new Span();
        superscriptElement.Typography.Variants = FontVariants.Superscript;
        foreach (var inline in superscript.Inlines)
        {
            superscriptElement.Inlines.Add(GetInline(inline));
        }
        return superscriptElement;
    }
}
\$\endgroup\$
  • \$\begingroup\$ I think everything below GetBlocks rendering each block could be extracted into another component and implemented nicely with the visitor-pattern which you virtually already did. I'm curious about the markdown parser by microsoft... may I please have a link? ;-] \$\endgroup\$ – t3chb0t Dec 29 '18 at 18:15
  • 2
    \$\begingroup\$ It's nuget.org/packages/Microsoft.Toolkit.Parsers. \$\endgroup\$ – Hosch250 Dec 29 '18 at 18:33
  • 1
    \$\begingroup\$ Love the idea of a rich-text editor for Markdown (being that there really aren't any I can find for WPF). What about making this a WPF Custom Control ( instead of a code-behind User Control). Then you can package it as a nuget package, and its re-themeable, etc. \$\endgroup\$ – TravisWhidden Mar 22 at 19:21
  • \$\begingroup\$ I did actually package it as a nuget package since I posted this! nuget.org/packages/MarkdownViewer. Do note that there are some UI quirks in this release. Codeblocks don't scroll correctly, unfortunately. I'd had that working at one point (I have a GIF to prove it), but I made some tweaks and broke it before I committed, so I don't know how to fix it. I've not really been maintaining the project, but it's OSS (Unlicense) at GitHub: github.com/Hosch250/MarkdownViewer \$\endgroup\$ – Hosch250 Mar 22 at 20:47
7
\$\begingroup\$
<RichTextBox x:Class="MarkdownViewer.MarkdownBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:MarkdownViewer"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             x:Name="Content">
</RichTextBox>

Why? In the interests of KISS, why not delete this entire file, and remove partial and the constructor from the declaration of the class?


    private static void PropertyChangedCallback(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        if (source is MarkdownBox control)
        {
            var newValue = (string)args.NewValue;
            switch (args.Property.Name)
            {
                case nameof(Text):
                    control.Text = newValue;
                    break;
            }
        }
    }

Either this should be deleted or it should be commented to explain why it does anything useful, because it seems to say

control.Text = control.Text

and be either a no-op or an infinite recursion.


        set
        {
            var old = GetValue(TextProperty);
            SetValue(TextProperty, value);
            OnPropertyChanged(new DependencyPropertyChangedEventArgs(TextProperty, old, value));

            SetTextboxContent();
        }

I think I see it now, and I think what this whole section should say is

    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(nameof(Text), typeof(string), typeof(MarkdownBox), new UIPropertyMetadata(default(string), TextPropertyChanged));

    private static void TextPropertyChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        // Assert that (source is MarkdownBox) && (args.Property.Name == nameof(Text)) if you want,
        // but IMO that's overkill. You've only registered the callback on one property.
        (source as MarkdownBox).SetTextboxContent();
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

And it seems to me that SetTextboxContent should be renamed UpdateTextboxContent.


    private void Hlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
    {
        Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri));
        e.Handled = true;
    }

Since you're packaging this as a library, it should expose the event rather than handling it.


    private IEnumerable<Block> GetBlocks(IList<MarkdownBlock> blocks)
    {
        foreach (var block in blocks)
        {
            switch (block)
            {
                case HeaderBlock header:
                    yield return GetHeaderBlock(header);
                    break;
                case ParagraphBlock paragraph:
                    yield return GetParagraphBlock(paragraph);
                    break;
                ...

Why IList instead of IEnumerable?

Also, I would be tempted to pull out GetBlock, which can be rather more compact:

    private Block GetBlock(MarkdownBlock block)
    {
        switch (block)
        {
            case HeaderBlock header:
                return GetHeaderBlock(header);
            case ParagraphBlock paragraph:
                return GetParagraphBlock(paragraph);
            ...

and reduce GetBlocks to

    private IEnumerable<Block> GetBlocks(IEnumerable<MarkdownBlock> blocks) => blocks.Select(GetBlock);

    private Block GetHeaderBlock(HeaderBlock header)
    {
        var headerLevels = new Dictionary<int, double>
        {
            [1] = 28,
            [2] = 21,
            [3] = 16.3833,
            [4] = 14,
            [5] = 11.6167,
            [6] = 9.38333,
        };

        var content = header.Inlines.Select(GetInline);
        var span = new Span();
        span.Inlines.AddRange(content);

        var labelElement = new Label
        {
            Content = span,
            FontSize = headerLevels[header.HeaderLevel]
        };
        var blockElement = new BlockUIContainer(labelElement);
        return blockElement;
    }

WPF, like HTML/CSS, allows separation of structure from style, but this actively works against that. At the very least the labels could use Tag so that I can write a style which uses triggers to override the hard-coded sizes, but since (again) this is published as a library I would look at defining separate subclasses for each of the header levels (or perhaps a single Header : Paragraph with a property HeaderLevel that can be used in a trigger) so that they can be styled directly. And the default values should be pulled out into Themes/Generic.xaml.

Also, why BlockUIContainer(Label) instead of Paragraph?


    private Block GetCodeBlock(CodeBlock code)
    {
        var typeConverter = new HighlightingDefinitionTypeConverter();
        var avalon = new TextEditor
        {
            Text = code.Text,
            SyntaxHighlighting = (IHighlightingDefinition)typeConverter.ConvertFrom("C#"),
            FontFamily = new FontFamily("Consolas"),
            FontSize = 12,
            Padding = new Thickness(10),
            BorderBrush = Brushes.LightGray,
            BorderThickness = new Thickness(1),
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            IsReadOnly = true,
            ShowLineNumbers = true,
            MaxHeight = 250
        };

        return new BlockUIContainer(avalon);
    }

Similar comments apply: the default style should be in XAML, and IMO a library shouldn't assume that code will be C#. Leave SyntaxHighlighting blank and document how to use highlighting for end users who want it.


    private Inline GetInline(MarkdownInline element)

I would re-order the code a bit to put this next to GetBlock as "high level" methods, and probably use a #region for the lower level methods for blocks and another for the inlines.


    private static Inline GetCodeInline(CodeInline code)
    {
        return new Run(code.Text)
        {
            Background = new SolidColorBrush(Color.FromRgb(0xef, 0xf0, 0xf1))
        };
    }

I'm surprised that this doesn't use the same monospaced font as the code block.


To finish, let me say that I love the concept. As it stands I wouldn't use the library, but if the issues I've raised (particularly about stylability) are addressed then it's possible I'll use it at some point in the future.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.