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
Comments
Anonymous
November 27, 2006
Hi Prajakta This article gave me a good start in dealing with the WPF's RichTextBox control. Basically what I'm looking for is a control that auto-detects links whenver entered into the document. For example, if I set the document text to "www.microsoft.com", it should auto-detect that link. I'm trying my best to get this to work, but I'm having selection issues; when I replace Run objects with Hyperlink objects, the selection sometimes gets reset to the beginning of the hyperlink. I'd really appreciate it if you could point me in the right direction.Anonymous
November 29, 2006
Hi, If you want to format hyperlinks when text changes programmatically (that is what I assume you mean by "set the document text"), you can listen to TextChanged event on RichTextBox. In the event handler you can scan words in the entire document, formatting hyperlinks. This approach wont really scale well for large documents. But unfortunately, there is no other way in the programmatic edit scenario. By the way, look at my latest post (auto-detecting hyperlinks part II) for updated code that can help you get started. There, I scan pasted text for hyperlinks. To answer your original question, you are probably having selection issues because caret position has backward gravity. When you delete the Run element to replace it with a Hyperlink, the backward caret position "gravitates" to position before the Run. Does this make sense? It is not that obvious. The workaround in this situation would be to remember caret position with forward gravity -TextPointer position = rtb.Selection.Start.GetPositionAtOffset(0, LogicalDirection.Forward; After you replace the Run with Hyperlink, set rtb.CaretPosition = position; Hope this answers your question. -Prajakta