Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

AnyLinkRichTextBox

0.00/5 (No votes)
8 Oct 2014 1  
This is an alternative for Links with arbitrary text in a RichTextBox.

Introduction

This control can recognize any kind of link as the user writes it. So, its use is not limited as is the use of that extended RichTextBox. This is a BIG article, so I have added an index to navigate through the explanation of the code.

Background

Some days ago I was searching for a way to insert custom links in a RichTextBox control. I have found the solution provided by mav-northwind, but I found it was very limited, because the user can't write a link at run time, all links have to be hardcoded at design time or inserted programatically!

I'd like to be able to detect links at run time, not only at design time, and to be able to modify a link if necessary, writing it, just like if I was writing a regular link in a regular RichTextBox. So I decided to write my own solution, and I will present it in this article.


P.S.: My solution is (in part) based on mav-northwind's solution, so I will not explain how to use Win32 API to apply the links, since this is already very well explained in his article. I use the same SetSelectionStyle method that he uses, since that is - IMHO - the only way to make an arbitrary text a link.

Using the control

To use the control, all you have to do is download the compiled dll, and reference it to your project. After that, you should see the AnyLinkRichTextBox control in your toolbox. Just drag and drop it to your form and use it like a regular RichTextBox.
If you prefer, you can download the source code and compile it yourself, too.

The code

Below I will show and explain the most relevant pieces of code. In a vain attempt of brevity, all the summaries and comments will not be shown.

I'll split the code in pieces, so if you want to know how some part of it works and ignore the others, you can jump to it from the list below:

The Pause and Resume Drawing functions

[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, int wParam, int lParam);

private const Int32 WM_SETREDRAW = 0xB;
private const int FALSE = 0;
private const int TRUE = 1;

private void SuspendDrawing()
{
    SendMessage(this.Handle, WM_SETREDRAW, FALSE, 0);
}

private void ResumeDrawing()
{
    SendMessage(this.Handle, WM_SETREDRAW, TRUE, 0);
    this.Invalidate();
}

These two functions are used to prevent the user to see any flickering or any step at all of the automatic process of search through the text for links (I will explain that process later).

I didn't write, and don't remember where I saw these functions for the first time, but they are really useful when you have to do any kind of graphical update at the form and want the user to see just the final result. :)

Back to Index

The Regexes

private static Regex customLinks = new Regex(
    @"\[.*\S.*\]\(.*\S.*\)",
    RegexOptions.IgnoreCase |
    RegexOptions.CultureInvariant |
    RegexOptions.Compiled);

private static Regex normalLinks = new Regex(
    @"(?<Protocol>\w+):\/\/(?<Domain>[\w@][\w.:@]+)\/?[\w\.?=%&=\-@/$,]*|(?<Domain>w{3}\.[\w@][\w.:@]+)\/?[\w\.?=%&=\-@/$,]*",
    RegexOptions.IgnoreCase |
    RegexOptions.CultureInvariant |
    RegexOptions.Compiled);

private static Regex IPLinks = new Regex(
    @"(?<First>2[0-4]\d|25[0-5]|[01]?\d\d?)\.(?<Second>2[0-4]\d|25[0-5]|[01]?\d\d?)\.(?<Third>2[0-4]\d|25[0-5]|[01]?\d\d?)\.(?<Fourth>2[0-4]\d|25[0-5]|[01]?\d\d?)",
    RegexOptions.IgnoreCase |
    RegexOptions.CultureInvariant |
    RegexOptions.Compiled);

private static Regex mailLinks = new Regex(
    @"(mailto:)?([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})",
    RegexOptions.IgnoreCase |
    RegexOptions.CultureInvariant |
    RegexOptions.Compiled);
#endregion

The regexes are used to identify the links that may be present in the text. Not much to explain here, just that is possible to change the regex customLinks to search for other format of customized link instead of the default Markdown style one.

Back to Index

The Variables

private Dictionary<KeyValuePair<int, int>, string> hyperlinks = new Dictionary<KeyValuePair<int, int>, string>();
private Point pt;
private char[] spliters;
private int OldLength;

The first variable (hyperlinks) is a Dictionary that will be used to store every custom hyperlink as value, and a KeyValuePair with the data of begin and length of text link as key.

The second variable (pt) is a point that will be used to get the exact position when the user clicks a link.

The third variable (spliters) is a char array that will store the delimiters chars for custom links.

And the last variable (OldLength) is used to store the text length before any changing, and will be used at the MoveCustomLinks method.

Back to Index

The Constructor

public AnyLinkRichTextBox()
{
    base.DetectUrls = false;
    this.DetectUrls = true;
    spliters = new char[] { '[', ']', '(', ')' };
    this.LinkClicked += RTBExCustomLinks_LinkClicked;
    this.TextChanged += RTBExCustomLinks_TextChanged;
    this.MouseMove += RTBExCustomLinks_MouseMove;
    this.Protected += RTBExCustomLinks_Protected;
}

Here is important notice a few things:

  • We must add 4 events to the handlers of the base Rich Text Box.
  • We must set the delimiters for custom links. These delimiters MUST be equal to the delimiters specified in the customLinks regex, otherwise some events will not behave like expected.
  • We have to set base.DetectUrls to false, because, as stated by mav-northwind in his article:
Quote:

"When the DetectUrls property is set to true and you modify the text adjacent to one of your links, the link formatting will be lost."

  • If we want the control to detect links, we must set this.DetectUrls to true.

"But wait! What's the difference between base.DetectUrls and this.DetectUrls?" - You may be asking now...

The difference is: the first is the DetectUrls property of the base control (RichTextBox), and the latter is a property that overrides it:

Back to Index

The Property

[Browsable(true),
DefaultValue(false)]
public new bool DetectUrls { get; set; }

That property is defined here to make sure that the base (RichTextBox) DetectUrls property will not be modified, but at the same time maintain the possibility that this control does not detect any kind of URL.

Back to Index

The Events

We have added 4 events at the constructor, remember? Now, I will explain each one:

RTBExCustomLinks_Protected

private void RTBExCustomLinks_Protected(object sender, EventArgs e)
{
    if (DetectUrls)
    {
        int i = 0;
        bool protectedBegin = true;
        bool protectedEnd = true;
        KeyValuePair<int, int> key;
        while (protectedBegin)
        {
            i++;
            int previous = this.SelectionStart - 1;
            this.Select(previous - 1, i);
            protectedBegin = this.SelectionProtected;
            this.Select(previous, i);
        }
        if (!(this.SelectionStart + this.SelectionLength == this.Text.Length))
        {
            while (protectedEnd && !(this.SelectionStart + this.SelectionLength == this.Text.Length))
            {
                i++;
                this.Select(this.SelectionStart, i);
                protectedEnd = this.SelectionProtected;
                this.Select(this.SelectionStart, i - 1);
            }
        }
        string text = this.SelectedText;
        this.SelectionProtected = false;
        key = new KeyValuePair<int, int>(this.SelectionStart, text.Length);
        this.SelectedText = String.Concat(spliters[0], text, spliters[1], spliters[2], hyperlinks[key]);
        hyperlinks.Remove(key);
    }
}

This event is used to make possible to the user change a custom link created previously.

When a custom link is created, only the friendly text will be displayed, and the hyperlink will be oculted (I will talk about this specifically when explain the CheckCustomLinks method). So, if the user wants to modify the hyperlink or the friendly text we must provide a safe way to do it.

The RTBExCustomLinks_Protected Event is used to it.

  • First, it checks wheter the DetectUrls is set to true. Then, it will find the begin of the link text.
  • Then it checks to see if we are at the end of text. If not, it will find the end of link text.
  • At this point, it will have selected the entire link text, so now, it will set the protection off.
  • And finally, replace the link text with the original format, removing only the last delimiter, e.g. [Link text](Hyperlink text
  • At last, it will remove the link of the dictionary of links.

Back to Index

RTBExCustomLinks_MouseMove

private void RTBExCustomLinks_MouseMove(object sender, MouseEventArgs e)
{
    pt = e.Location;
}

That is the simpliest event of the control. It just monitor the position of the mouse when it is above the control, and stores that data to the point pt.

Back to Index

RTBExCustomLinks_LinkClicked

private void RTBExCustomLinks_LinkClicked(object sender, LinkClickedEventArgs e)
{
    if (DetectUrls)
    {
        if (normalLinks.IsMatch(e.LinkText))
        {
            Process.Start(e.LinkText);
        }
        else if (mailLinks.IsMatch(e.LinkText))
        {
            Process.Start("mailto:" + e.LinkText);
        }
        else if (IPLinks.IsMatch(e.LinkText))
        {
            Process.Start("http://" + e.LinkText);
        }
        else
        {
            int mouseClick = this.GetCharIndexFromPosition(pt);
            try
            {
                var linkClicked = hyperlinks.Where(k => IsInRange(mouseClick, k.Key.Key, k.Key.Value));
                string hyperlinkClicked = linkClicked.Select(k => k.Value).ToList().First();
                this.SelectionStart = linkClicked.Select(k => k.Key.Key).First() + linkClicked.Select(k => k.Key.Value).First();
                Process.Start(hyperlinkClicked);
            }
            catch (Exception)
            {
                MessageBox.Show("The link is not valid!");
            }
        }
    }
}

This event will start the right process for every kind of link.
First, it will check if the DetectUrls is set to true, and if it is, the event will check the clicked link text against the regexes until it find the right kind.

  • If it is a regular link, i.e. starting with a regular protocol (http://|https://|etc.) or with www.
  • If it is a mail like link, e.g. user@company.com or mailto:user@company.com
    • (Note: For the mail links work, the protocol mailto: MUST be at the start of link. But the mailLinks regex recognizes as a mail link any text in the format user@company.com with or without the protocol, so the user have a little more freedom. Here we take care of the possible lack of protocol)
  • If it is a IP like link, e.g. 255.255.255.255
    • (Note: For the IP links work, the protocol http:// MUST be at the start of link. But the IPLinks regex recognizes as a IP link any text in the format ###.###.###.###, where # = [0-255]. If the user writes it with the protocol, the normalLinks regex will take care of it.)
  • At last, if the link text does not match any of the other regexes, we try to parse the hyperlink from the friendly text
    • First we get the char index form the mouse position, using the point pt
    • Then, we locate the hyperlink at the dictionary hyperlinks, based on that char index. How? Simple, we use the IsInRange method (not shown in this article, look at the source code if you are curious) to check wheter the char index is located between the first and last chars of friendly text, for every hyperlinks key. When this method returns true we store the hyperlinks KeyValuePair in the local variable linkClicked:
    • Then, we set the hyperlinkClicked variable with the hyperlink which is stored at the linkClicked value
    • And, finally, we start the process with the hyperlinkClicked as argument
    • If there are any problem in that process, we assume that the hyperlink is not valid, and notify the user

Back to Index

RTBExCustomLinks_TextChanged

private void RTBExCustomLinks_TextChanged(object sender, EventArgs e)
{
    if (DetectUrls)
    {
        SuspendDrawing();
        int pos = this.SelectionStart;

        pos = CheckCustomLinks(pos);
        MoveCustomLinks();
        RemoveLinks();
        
        CheckNormalLinks();
        CheckMailLinks();
        CheckIPLinks();

        RefreshCustomLinks();

        if (pos > 0)
        {
            this.Select(pos, 0);
        }
        else
        {
            this.Select(0, 0);
        }
        ResumeDrawing();
    }
}

That is the core of the whole process. Is at that event that we identify all the links and any eventual changes in any of them.

Using the TextChanged Event we can create, modify or delete links at run time.

It will check if the DetectUrls is set to true, and if it is it will proceed to search for links. The first thing to do is suspend the drawing, for the reasons explained before.

Then, we need to store the position of the caret, so if any changes in the text are made we can set the caret to where it was before. That way the user can continue to write without any problems.

Now I will explain each method of that event:

Back to Index

The Methods of TextChanged Event

CheckCustomLinks

private int CheckCustomLinks(int pos)
{
    if (customLinks.Matches(this.Text).Cast<Match>().Any())
    {
        var linksCustom = customLinks.Matches(this.Text).Cast<Match>().Select(n => n).ToList();
        foreach (var item in linksCustom)
        {
            var parsedLink = item.Value.Split(spliters, StringSplitOptions.RemoveEmptyEntries);
            string text = parsedLink[0];
            string hyperlink = parsedLink[1];
            int start = item.Index;
            int length = item.Length;
            KeyValuePair<int, int> key = new KeyValuePair<int, int>(start, text.Length);
            if (hyperlinks.ContainsKey(key) || hyperlinks.Keys.Any(k => k.Key == key.Key))
            {
                hyperlinks.Remove(key);
                hyperlinks.Add(key, hyperlink);
            }
            else
            {
                hyperlinks.Add(key, hyperlink);
            };

            this.SelectionStart = start;
            this.Select(start, length);
            this.SelectedText = text;
            this.Select(start, text.Length);
            this.SetSelectionStyle(CFM_LINK, CFE_LINK);
            this.SelectionProtected = true;
            int pos2 = (pos - length) + text.Length;
            if (pos2 > 0)
            {
                this.Select(pos2, 0);
                pos = pos2;
            }
            else this.Select(0, 0);
        }
    }
    return pos;
}

This method parses the text looking for any text that matches the customLinks regex.

  • If it finds, it will store any Match in the local variable (List) linksCustom, and for every element of linksCustom, it will separate the friendly text of the hyperlink
  • Next, it will create a KeyValuePair key for the hyperlinks dictionary
  • Then, it will check if the created key already exists in the dictionary hyperlinks OR if there are any entry in the dictionary hyperlinks which KeyValuePair key has the same key as the created KeyValuePair key. If any of these conditions is true, it will remove the entry from the dictionary and add a new one
    • The second conditional is necessary because if the length of friendly text (i.e. the value of the KeyValuePair key in the dictionary) changes, but the start index does not, we still need to update that data in the dictionary
  • If both conditionals are false, it will just add the new entry
  • Now, it will select the entire text, e.g. [friendly text](hyperlink) and replace it with just the friendly text
    • When we set a new value to the SelectedText property, the selected text becomes "", so we need to select the friendly text again
  • And then, we use the SetSelectionStyle method to make it a link. At last, we protect the friendly text to be able to identify when the user wants to make any change in the link
  • The last thing to do here is calculate the new position for the caret based on the changes made in the text, and return that position

Back to Index

MoveCustomLinks

private void MoveCustomLinks()
{
    int lengthDiff = 0;
    if (OldLength != 0)
    {
        lengthDiff = this.Text.Length - OldLength;
    }

    if (hyperlinks.Any() && lengthDiff != 0)
    {
        var keysToUpdate = new List<KeyValuePair<int, int>>();

        foreach (var entry in hyperlinks)
        {
            keysToUpdate.Add(entry.Key);
        }

        foreach (var keyToUpdate in keysToUpdate)
        {
            var value = hyperlinks[keyToUpdate];
            int newKey;
            if (this.SelectionStart <= keyToUpdate.Key + lengthDiff)
            {
                newKey = keyToUpdate.Key + lengthDiff;
            }
            else
            {
                newKey = keyToUpdate.Key;
            }

            hyperlinks.Remove(keyToUpdate);
            hyperlinks.Add(new KeyValuePair<int, int>(newKey, keyToUpdate.Value), value);
        }
    }
    OldLength = this.Text.Length;
}

Now that we have changed the length of the text by replacing the full format of custom link with the friendly text, it is necessary that we recalculate the position of every custom link.

That method does that.

  • First, it calculates the difference caused by the replacing, using the variable OldLength as reference
    • (Note: If OldLength is ZERO, it means that there was no text before, and now there is, so we don't change the default value of lengthDiff)
    • We only make any recalculation if:
  1. There are any custom links
  2. lengthDiff is not ZERO, i.e. the text has changed 
  • We make a copy of every hyperlinks key (every key here is a KeyValuePair) to a list (keysToUpdate)
  • Then, for every item in keysToUpdate, we will recalculate the key of the item, but only if the change in the text was made in a position BEFORE the item (considering the lengthDiff)
  • At last, we remove the obsolete entry from the dictionary and add a new one, with the recalculated value, and set the text current Length as OldLength value

Back to Index

RemoveLinks

private void RemoveLinks()
{
    this.SelectAll();
    this.SelectionProtected = false;
    this.SetSelectionStyle(CFM_LINK, 0);
}

This method is very simple. It just select all text, remove any protection that may be present (i.e. if there are any custom link) and remove the link effect of all text.

That way, all links are gone. So now, we need to search for the links again:

Back to Index

CheckNormalLinks

private void CheckNormalLinks()
{
    if (normalLinks.Matches(this.Text).Cast<Match>().Any())
    {
        var linksNormal = normalLinks.Matches(this.Text).Cast<Match>().Select(n => n).ToList();
        foreach (var item in linksNormal)
        {
            this.Select(item.Index, item.Length);
            this.SetSelectionStyle(CFM_LINK, CFE_LINK);
        }
    }
}

This method parses the text looking for any text that matches the normalLinks regex.

  • If it finds, it will store any Match in the local variable (List) linksNormal
  • For every element of linksNormal, it:
    • Selects the text
    • Set the link effect with the SetSelectionStyle method

Back to Index

CheckMailLinks

private void CheckMailLinks()
{
    if (mailLinks.Matches(this.Text).Cast<Match>().Any())
    {
        var linksMail = mailLinks.Matches(this.Text).Cast<Match>().Select(n => n).ToList();
        foreach (var item in linksMail)
        {
            this.Select(item.Index, item.Length);
            this.SetSelectionStyle(CFM_LINK, CFE_LINK);
        }
    }
}

This method parses the text looking for any text that matches the mailLinks regex.

  • If it finds, it will store any Match in the local variable (List) linksMail
  • For every element of linksMail, it:
    • Selects the text
    • Set the link effect with the SetSelectionStyle method

Back to Index

CheckIPLinks

private void CheckIPLinks()
{
    if (IPLinks.Matches(this.Text).Cast<Match>().Any())
    {
        var linksIP = IPLinks.Matches(this.Text).Cast<Match>().Select(n => n).ToList();
        foreach (var item in linksIP)
        {
            this.Select(item.Index, item.Length);
            this.SetSelectionStyle(CFM_LINK, CFE_LINK);
        }
    }
}

This method parses the text looking for any text that matches the IPLinks regex.

  • If it finds, it will store any Match in the local variable (List) linksIP
  • For every element of linksIP, it:
    • Selects the text
    • Set the link effect with the SetSelectionStyle method.

Back to Index

RefreshCustomLinks

private void RefreshCustomLinks()
{
    foreach (var item in hyperlinks.Keys)
    {
        this.Select(item.Key, item.Value);
        this.SetSelectionStyle(CFM_LINK, CFE_LINK);
        this.SelectionProtected = true;
    }
}

This method will restore the custom links.

  • It looks at the hyperlinks dictionary and for every entry it will use the key to select the link text.
  • Then it sets the link effect with the SetSelectionStyle method
  • And, at last, it sets the selection as protected text, so we can know when the user wants to modify/delete a custom link.

Back to Index

Conclusion

That is my solution. I know that may be better ways to do what I did here. After all, I am no expert in C# programming.

And I know that certainly are better ways to explain it! But I am not a native English speaker, and I am (slightly) better programmer than teacher.

So if you have any comments or suggestions, or if you see any error (English grammar/syntax || C# grammar/syntax :)), please let me know! Write a comment in the comments section below, or open an issue at the page of the project at GitHub.

History

  • 08/10/2014

          Initial release.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here