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:
- There are any custom links
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
Initial release.