Friday, November 13, 2009

Inline Hyperlinks in Silverlight 3 (Equivalent of A HREF)

One common complaint I see regarding Silverlight is the inability to include inline hyperlinks. While Silverlight does provide a HyperlinkButton, you cannot do something simple like:


This is some text. This is <a href="http://www.wintellect.com/">a hyperlink</a>, in case you were wondering. 

There are a few solutions available via third-party controls, but it's a good exercise to understand the underlying fundamentals of the rendering engine, controls, and objects that make up a TextBlock. In this article, we'll set out on a quest to be able to take a simple set of text, formatted for a text block, and generate inline links.

The first step is to simply open your solution, start a new Silverlight 3 application, and get rolling with some text. The easiest way to grab text is to visit Lorem Ipsum, where you can request however much text you need and it will generate the paragraphs for you. I just grabbed a few sentences and set the width of my control to 200 so I could show the text wrapping. The final markup looks like this (I called my project "SLHyperlink"):

<UserControl x:Class="SLHyperlink.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
   mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480"
   Width="200">
      <Grid x:Name="LayoutRoot">
        <TextBlock TextWrapping="Wrap">Lorem ipsum dolor sit amet, consectetur adipiscing elit. 
            Aenean eget turpis id purus tempor tincidunt porttitor eu mi. 
            Vestibulum tincidunt odio quis nibh feugiat faucibus. 
            Fusce rhoncus tristique mi non posuere. 
            Nunc sit amet velit magna.
  </Grid>
</UserControl>

When I run the solution, this is what appears:

Lorem Ipsum

Now, we can get a little fancier. If you are familiar with the TextBlock control, you know you can mix and match different styles of text within the control, and even provide line breaks. You do this using the Run and the LineBreak markup. Let's spruce up the text a bit. I made the font size larger, added a few runs and a line break, and changed the width of the control to 300 to accommodate the larger text:


<TextBlock TextWrapping="Wrap" FontSize="20"><Run FontFamily="Times New Roman">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</Run> 
            Aenean eget turpis id purus tempor tincidunt porttitor eu mi. 
            <Run Foreground="Cyan">Vestibulum tincidunt odio quis nibh feugiat faucibus.</Run> 
            Fusce rhoncus tristique mi non posuere.<LineBreak/>
            Nunc sit amet velit magna.</TextBlock>

The result is this:

Lorem Ipsum

Anatomy of a TextBlock

Now that we have the inline text, we can start to dissect the classes. There are a few tools at our disposal. First, we can go into the Object Browser in Visual Studio and search for TextBlock. We find out that it inherits directly from FrameworkElement and is, unfortunately, a sealed class, so we cannot create our own dervied class. There are plenty of text-related dependency properties (font size, font family, font weight, etc) and then a few interesting methods like OnCreateAutomationPeer and the collection of Inlines.

Running RedGate's free .Net Reflector tool, we can generate the shell of the class:


[ContentProperty("Inlines", true)]
public sealed class TextBlock : FrameworkElement
{
    // Fields
    private FontSource _fontSource;
    public static readonly DependencyProperty FontFamilyProperty;
    public static readonly DependencyProperty FontSizeProperty;
    public static readonly DependencyProperty FontStretchProperty;
    public static readonly DependencyProperty FontStyleProperty;
    public static readonly DependencyProperty FontWeightProperty;
    public static readonly DependencyProperty ForegroundProperty;
    private static readonly DependencyProperty InlinesProperty;
    public static readonly DependencyProperty LineHeightProperty;
    public static readonly DependencyProperty LineStackingStrategyProperty;
    public static readonly DependencyProperty PaddingProperty;
    public static readonly DependencyProperty TextAlignmentProperty;
    public static readonly DependencyProperty TextDecorationsProperty;
    public static readonly DependencyProperty TextProperty;
    public static readonly DependencyProperty TextWrappingProperty;

    // Methods
    static TextBlock();
    public TextBlock();
    internal override string GetPlainText();
    protected override AutomationPeer OnCreateAutomationPeer();
    private void UpdateFontSource(FontSource fontSource);

    // Properties
    public FontFamily FontFamily { get; set; }
    public double FontSize { get; set; }
    public FontSource FontSource { get; set; }
    public FontStretch FontStretch { get; set; }
    public FontStyle FontStyle { get; set; }
    public FontWeight FontWeight { get; set; }
    public Brush Foreground { get; set; }
    public InlineCollection Inlines { get; }
    public double LineHeight { get; set; }
    public LineStackingStrategy LineStackingStrategy { get; set; }
    public Thickness Padding { get; set; }
    public string Text { get; set; }
    public TextAlignment TextAlignment { get; set; }
    public TextDecorationCollection TextDecorations { get; set; }
    public TextWrapping TextWrapping { get; set; }
}

What's interesting for us is the Inlines collection. In Silverlight, the System.Windows.Documents.Inline class is summarized as "An abstract class that provides a base for all inline flow content elements." There are exactly two classes that inherit from them, and both of them are sealed: Run and LineBreak. The Run class simply contains a text property:


[ContentProperty("Text", true)]
public sealed class Run : Inline
{
    // Fields
    private static readonly DependencyProperty TextProperty;

    // Methods
    static Run();
    public Run();

    // Properties
    public string Text { get; set; }
}

It turns out if you give your TextBlock a name (x:Name="tbSomething") and then debug, you'll find that the text we entered inside the TextBlock is turned into a collection. The free text goes into a run without additional attributes, so the collection is of runs and line breaks. Not only are these classes sealed, but they inherit directly from DependencyObject, so they are not FrameworkElement (or even UIElement) derived. This means we can't do things like check mouse events or bind to click events.

Instead of creating our own text block control, it appears that the existing has everything we need except the actual hyperlink. After brainstorming for a bit, I decided the easiest way to implement this would be to translate the text block into a wrap panel. A wrap panel does what we want: it allows elements of various sizes to stack together (like text flowing together). If we want an inline hyperlink, we simply need to stack a text block, followed by a navigation button for the hyperlink, followed by another text block. We can separate out the hyperlink by placing it in its own run.

An Extended Run

Even though our Run class is sealed, it derives from Inline which, in turn, is a DependencyObject, so we are allowed to generate attached properties.

The first thing I did was create a static class called RunExtender and add an attached property for a NavigateUrl. This is what I can attach to the run that will become a hyperlink. The code looks like this:


public static class RunExtender 
{  
    public static Uri GetNavigateUrl(DependencyObject obj)
    {
        return (Uri)obj.GetValue(NavigateUrlProperty);
    }
    
    public static void SetNavigateUrl(DependencyObject obj, Uri value)
    {
        obj.SetValue(NavigateUrlProperty, value); 
    }

    public static readonly DependencyProperty NavigateUrlProperty =
        DependencyProperty.RegisterAttached("NavigateUrl",
                                    typeof(Uri),
                                    typeof(RunExtender),
                                    null);
}

Now I can add an inline hyperlink and add the attached navigation property. This won't change anything yet, but it sets us up for the next iteration.

First, I add the namespace to the top of the control:


xmlns:local="clr-namespace:SLHyperlink"

Next, I add some more text to the text block and decorate it with the new attached property:


<TextBlock TextWrapping="Wrap" FontSize="20"><Run FontFamily="Times New Roman">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</Run> 
            Aenean eget turpis id purus tempor tincidunt porttitor eu mi. 
            Here is an <Run local:RunExtender.NavigateUrl="http://www.wintellect.com/">inline hyperlink</Run>.
            <Run Foreground="Cyan">Vestibulum tincidunt odio quis nibh feugiat faucibus.</Run> 
            Fusce rhoncus tristique mi non posuere.<LineBreak/>
            Nunc sit amet velit magna.</TextBlock>

The generated output isn't interesting yet, but no errors so far:

Lorem Ipsum

Warping the WrapPanel

Now things are getting interesting. What I want to do is make my text block a resource, then move it into a wrap panel. Attached properties come to our rescue again, because I can create an attached property for a wrap panel that points to my text block resource. When it is attached, we'll parse the inline collection and build out a new set of children for the wrap panel to render.

We're going to have to move a lot of values from the reference text block to our targets in the wrap panel. The first thing I did was to add some static collections of attributes for the text block, inline element, and a generic control (in the case of the hyperlink button we'll generate). I also want to clean the text as we move it from one to the other, so I set up a regular expression to help strip whitespace. This block looks like:


private static FieldInfo[] _tbInfo = typeof(TextBlock).GetFields(BindingFlags.Static | BindingFlags.Public);
private static FieldInfo[] _rInfo = typeof(Inline).GetFields(BindingFlags.Static | BindingFlags.Public);
private static FieldInfo[] _cInfo = typeof(Control).GetFields(BindingFlags.Static | BindingFlags.Public);
private static readonly Regex _reg = new Regex(@"\s+");

Notice we are just getting the public and static properties from the types.

The clone method I created could probably be refactored and cleaned up several times over, but it does the trick. I want to be able to transfer properties from a source to a target. If the source and target are the same type, we can reference the same dependency properties. If they are different types, we'll need to lookup the dependency property on the target that has the same name as the dependency property on the source. Take a peek at this:


private static void _Clone(DependencyObject src, DependencyObject tgt, FieldInfo[] fi)
{
    _Clone(src, tgt, fi, null);
}

private static void _Clone(DependencyObject src, DependencyObject tgt, FieldInfo[] fi, FieldInfo[] fiTgt)
{
    for (int i = 0; i < fi.Length; i++)
    {
        DependencyProperty dp = fi[i].GetValue(src) as DependencyProperty;

        bool eligible = dp != null;

        if (eligible && src is TextBlock)
        {
            eligible = fi[i].Name.StartsWith("Font") || fi[i].Name.StartsWith("Line") || fi[i].Name.StartsWith("TextAlignment")
                || fi[i].Name.StartsWith("TextWrapping") ||
                (!(tgt is HyperlinkButton) && fi[i].Name.StartsWith("Foreground"));
        }
        else if (eligible && src is Run) 
        {
               eligible = fi[i].Name.StartsWith("Font") || fi[i].Name.StartsWith("Foreground");
        }

        if (eligible)
        {
           DependencyObject obj = src.GetValue(dp) as DependencyObject;
            if (obj != null)
            {
                if (fiTgt == null)
                {
                    tgt.SetValue(dp, obj);
                }
                else
                {
                    FieldInfo fNew = null;
                        foreach (FieldInfo fInfo in fiTgt)
                        {
                            if (fInfo.Name.Equals(fi[i].Name))
                            {
                                fNew = fInfo;
                                break;
                            }
                        }
                        if (fNew != null)
                        {
                            DependencyProperty dpTgt = fNew.GetValue(tgt) as DependencyProperty;
                            tgt.SetValue(dpTgt, obj); 
                        }
                }
            }
            else
            {
                
                    if (fiTgt == null)
                    {
                        tgt.SetValue(dp, src.GetValue(dp));
                    }
                    else
                    {
                        FieldInfo fNew = null;
                        foreach (FieldInfo fInfo in fiTgt)
                        {
                            if (fInfo.Name.Equals(fi[i].Name))
                            {
                                fNew = fInfo;
                                break;
                            }
                        }
                        if (fNew != null)
                        {
                            DependencyProperty dpTgt = fNew.GetValue(tgt) as DependencyProperty;
                            tgt.SetValue(dpTgt, src.GetValue(dp)); 
                        }
                    }
            }
        }
    }
}

(Yes, I know there is some duplicated code that could be pulled out into another method)

So we essentially have our source and target, and the collection that contains the fields and properties. I only send a second collection in if the target is different.

We begin by iterating all properties on the source. We need to make sure we get a valid reference. I then filter the properties to only move what I'm interested in. For the text block, it's the font, line, and foreground properties, as well as the text alignment and text wrappig. I don't want, for example, to get the "Height" property (we'll let the engine size it for us) and I certainly don't want to move the "Text" property (we'll be iterating the collection of inlines for that).

For the run, it's just font information and the foreground.

If the value is a nested object (for example, a brush), we simply set the value on the target. If the target is a different type, I first iterate the target list of properties to find the property with the same name, then set the object to that.

If it's not a nested object, I get the value, then set the value on the target. Again, if the target is a different type, I have to find the corresponding dependency for the target and then set the value on that.

Now that we've got some nice helpers in place, we can set up a new attached property that points to a text block. Remember, my goal is to take that in, parse it, and output elements into a wrap panel.

Here is the setup:


public static TextBlock GetTargetTextBlock(DependencyObject obj)
{
    return (TextBlock)obj.GetValue(TargetTextBlockProperty);
}

public static void SetTargetTextBlock(DependencyObject obj, TextBlock value)
{
    obj.SetValue(TargetTextBlockProperty, value); 
}

public static readonly DependencyProperty TargetTextBlockProperty =
    DependencyProperty.RegisterAttached("TargetTextBlock",
                                typeof(TextBlock),
                                typeof(RunExtender),
                                new PropertyMetadata(null, new PropertyChangedCallback(OnTargetAttached)));

And now it's time to do the actual parsing. What I want to do is take each run and make it either a new text block (we are mapping to multiple text blocks because the wrap panel is going to wrap on the outermost container ... if we put all the runs inside a single text block, it will only wrap in the text block and not in the context of the wrap panel container) or a hyperlink button. The hyperlink button will take on the attributes of the text block except the foreground (we'll keep the default of that being blue to show that it's a link). All of these will become children of the new wrap panel.

Don't forget that the wrap panel exists in the toolkit that you can download here. We simply need to add a reference to System.Windows.Controls.Toolkit in our project.

Here's the code:


public static void OnTargetAttached(object obj, DependencyPropertyChangedEventArgs args)
{
    WrapPanel panel = obj as WrapPanel;
    if (panel != null)
    {
        TextBlock src = args.NewValue as TextBlock;
        if (src != null)
        {
            foreach (Inline inline in src.Inlines)
            {
                if (inline is LineBreak)
                {
                    TextBlock newBlock = new TextBlock();
                    newBlock.Inlines.Add(new LineBreak());
                    panel.Children.Add(newBlock); 
                }
                else if (inline is Run)
                {
                    Run run = inline as Run;
                    Uri navigateUri = run.GetValue(NavigateUrlProperty) as Uri;
                    if (navigateUri != null)
                    {
                       HyperlinkButton button = new HyperlinkButton();
                        button.Content = run.Text;
                        button.NavigateUri = navigateUri;
                        _Clone(src, button, _tbInfo, _cInfo); 
                        panel.Children.Add(button);
                    }
                    else
                    {
                        Run newRun = new Run { Text = _reg.Replace(run.Text.Replace('\n',' ')," ").Trim() };
                        _Clone(run, newRun, _rInfo);
                        TextBlock newBlock = new TextBlock();
                        _Clone(src, newBlock, _tbInfo);
                        newBlock.Inlines.Add(newRun);
                        panel.Children.Add(newBlock);
                    }
                }
            }
        }
    }
}

Now that we've got that all in place, it's time to update our main page. First, we'll add a reference to the toolkit:


xmlns:controls="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Toolkit"

Next, we pull our text block into the resources section and give it a key. This makes it a resource now instead of an inline text block:


<UserControl.Resources>
        <TextBlock x:Key="tb" TextWrapping="Wrap" FontSize="20"><Run FontFamily="Times New Roman">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</Run> 
            Aenean eget turpis id purus tempor tincidunt porttitor eu mi.<LineBreak/> 
            Here is an <Run local:RunExtender.NavigateUrl="http://www.wintellect.com/">inline hyperlink</Run>.
            <Run Foreground="Cyan">Vestibulum tincidunt odio quis nibh feugiat faucibus.</Run> 
            Fusce rhoncus tristique mi non posuere.<LineBreak/>
            Nunc sit amet velit magna.</TextBlock>
    </UserControl.Resources>

In our main grid, instead of the text block, we'll now use a wrap panel and set the text block as the target:


<Grid x:Name="LayoutRoot">
   <controls:WrapPanel local:RunExtender.TargetTextBlock="{StaticResource tb}"/>
</Grid>

Now we run it, and this is what we get:

Lorem Ipsum

It's the same text as before, but with a nice, clickable hyperlink. Clicking the hyperlink validates that it does indeed take us to the Wintellect home page.

Now you can add inline hyperlinks to your heart's desire simply by decorating the run and using the wrap panel.

There are several third party controls available that help with rendering HTML and hyperlinks that you can use, but I always believe it's helpful to understand the how and why. Hopefully this exercise assisted you with a better understanding of dependency objects and properties and more specifically the text rendering functionality that is available out of the box with Silverlight 3.

You may download this example here.

Jeremy Likness

3 comments:

  1. I had to add button.TargetName = "_blank" to get it to work with Silverlight 3 inside of a RIA project. I assume it would be the same under a Navigation project.

    ReplyDelete
  2. Have you tried to make the run bindable?

    ReplyDelete
  3. Not work if text before hyperlink more than 1 line :(

    ReplyDelete