Auto-detecting Hyperlinks in RichTextBox - Part I

In this post, we will see a custom RichTextBox implementation that auto-detects Hyperlinks while typing. In a later post, I will demonstrate how auto-detection can be done on paste command. Both these parts together enable complete auto-detect support for hyperlinks in a RichTextBox.

For simplicity, in this demo, my code detects the string www.microsoft.com as a hyperlink. A real app would need a more sophisticated RegEx match expression. Here is a snapshot of the result.

For auto-detection while typing, there are 2 tasks. First is auto-creating hyperlink element on space and enter keys. Second is auto-deleting it on backspace key. For this, we intercept KeyDown events on the RichTextBox. Note that we dont set e.Handled to true, since we want the base RichTextBox to do the work of inserting/deleting the space character/enter break.

    /// <summary>

    /// Custom RichTextBox class that performs hyperlink auto-detection.

    /// </summary>

    public class MyRichTextBox : RichTextBox

    {

        // Ctor.

        static MyRichTextBox()

        {

            // KeyDown event.

            EventManager.RegisterClassHandler(typeof(MyRichTextBox), KeyDownEvent, new KeyEventHandler(OnKeyDown), /*handledEventsToo*/true);

        }

When user enters space or enter key, we want to check preceeding text. If it matches a hyperlink, we want to insert a hyperlink tag around it. 

            TextPointer caretPosition = myRichTextBox.Selection.Start;

            if (e.Key == Key.Space || e.Key == Key.Return)

            {

                TextPointer wordStartPosition;

                string word = GetPreceedingWordInParagraph(caretPosition, out wordStartPosition);

                if (word == "www.microsoft.com") // A real app would need a more sophisticated RegEx match expression for hyperlinks.

                {

                    // Insert hyperlink element at word boundaries.

                    new Hyperlink(

                        wordStartPosition.GetPositionAtOffset(0, LogicalDirection.Backward),

                        caretPosition.GetPositionAtOffset(0, LogicalDirection.Forward));

 

The toughest part of this task is finding preceeding "word" in the document.  In a simple case, a word is within a single Run element inside the document. In a complex case, when sub-parts of a word are formatted (bold, italic etc), the word can span different Inline elements. Below helper routine demonstrates how one can use TextPointer API to accomplish this. The idea is to keep traversing backwards from current position till its Paragraph's start until you find a word break (space character for simplicity).

 

        // Helper that returns a word preceeding the passed position in its paragraph,

        // wordStartPosition points to the start position of word.

        private static string GetPreceedingWordInParagraph(TextPointer position, out TextPointer wordStartPosition)

        {

            wordStartPosition = null;

            string word = String.Empty;

            Paragraph paragraph = position.Paragraph;

            if (paragraph != null)

            {

                TextPointer navigator = position;

                while (navigator.CompareTo(paragraph.ContentStart) > 0)

                {

                    string runText = navigator.GetTextInRun(LogicalDirection.Backward);

                    if (runText.Contains(" ")) // Any globalized application would need more sophisticated word break testing.

                    {

                        int index = runText.LastIndexOf(" ");

                        word = runText.Substring(index + 1, runText.Length - index - 1) + word;

                        wordStartPosition = navigator.GetPositionAtOffset(-1 * (runText.Length - index - 1));

                        break;

                    }

                    else

                    {

                        wordStartPosition = navigator;

                 word = runText + word;

                    }

                    navigator = navigator.GetNextContextPosition(LogicalDirection.Backward);

                }

            }

            return word;

        }

 

Now lets look at the code that deletes the hyperlink tag on backspace key. How do you detect if backspace is at the hyperlink's end boundary?

 

            else // Key.Back

            {

                TextPointer backspacePosition = caretPosition.GetNextInsertionPosition(LogicalDirection.Backward);

                Hyperlink hyperlink;

                if (backspacePosition != null && IsHyperlinkBoundaryCrossed(caretPosition, backspacePosition, out hyperlink))

                {

 

Lets look at the helper IsHyperlinkBoundaryCrossed that detects this for us. It uses a heuristic based on hyperlink ancestors of current position and backspace position. The comments in the code are self explanatory, so I will just show the code here.

 

        // Helper that returns true if passed caretPosition and backspacePosition cross a hyperlink end boundary

        // (under the assumption that caretPosition and backSpacePosition are adjacent insertion positions).

        private static bool IsHyperlinkBoundaryCrossed(TextPointer caretPosition, TextPointer backspacePosition, out Hyperlink backspacePositionHyperlink)

        {

            Hyperlink caretPositionHyperlink = GetHyperlinkAncestor(caretPosition);

            backspacePositionHyperlink = GetHyperlinkAncestor(backspacePosition);

            return (caretPositionHyperlink == null && backspacePositionHyperlink != null) ||

                (caretPositionHyperlink != null && backspacePositionHyperlink != null && caretPositionHyperlink != backspacePositionHyperlink);

        }

        // Helper that returns a hyperlink ancestor of passed position.

        private static Hyperlink GetHyperlinkAncestor(TextPointer position)

        {

            Inline parent = position.Parent as Inline;

            while (parent != null && !(parent is Hyperlink))

            {

                parent = parent.Parent as Inline;

            }

            return parent as Hyperlink;

        }

 

Finally, the process of deleting the hyperlink element consists of below steps.

 

                    // 1. Copy its children Inline to a temporary array.

                    InlineCollection hyperlinkChildren = hyperlink.Inlines;

                    Inline[] inlines = new Inline[hyperlinkChildren.Count];

                    hyperlinkChildren.CopyTo(inlines, 0);

                    // 2. Remove each child from parent hyperlink element and insert it after the hyperlink.

                    for (int i = inlines.Length - 1; i >= 0; i--)

                    {

                        hyperlinkChildren.Remove(inlines[i]);

                        hyperlink.SiblingInlines.InsertAfter(hyperlink, inlines[i]);

                    }

                    // 3. Apply hyperlink's local formatting properties to inlines (which are now outside hyperlink scope).

                    LocalValueEnumerator localProperties = hyperlink.GetLocalValueEnumerator();

                    TextRange inlineRange = new TextRange(inlines[0].ContentStart, inlines[inlines.Length - 1].ContentEnd);

                    while (localProperties.MoveNext())

                    {

                        LocalValueEntry property = localProperties.Current;

                        DependencyProperty dp = property.Property;

                        object value = property.Value;

                        if (!dp.ReadOnly &&

                            dp != Inline.TextDecorationsProperty && // Ignore hyperlink defaults.

                         dp != TextElement.ForegroundProperty &&

                            !IsHyperlinkProperty(dp))

                        {

                            inlineRange.ApplyPropertyValue(dp, value);

                        }

                    }

             // 4. Delete the (empty) hyperlink element.

                    hyperlink.SiblingInlines.Remove(hyperlink);

 

I have attached all the code to this post. The custom RichTextBox is ~150 lines of code, enjoy!

RTB_Hyperlink_AutoDetect_Part_I.zip