AnyLinkRichTextBox





5.00/5 (1投票)
这是“RichTextBox 中的任意文本链接”的替代方案。
引言
此控件可以在用户输入时识别任何类型的链接。因此,它的使用不像那个扩展的 RichTextBox 那样有限。这是一篇很长的文章,所以我添加了一个索引来导航代码解释。
背景
几天前,我正在寻找一种在 RichTextBox 控件中插入自定义链接的方法。我找到了 mav-northwind 提供的解决方案,但我发现它非常有限,因为用户无法在运行时编写链接,所有链接都必须在设计时硬编码或通过编程方式插入!
我想在运行时检测链接,而不仅仅是在设计时,并且能够根据需要修改链接,就像在普通 RichTextBox 中编写常规链接一样。所以,我决定编写自己的解决方案,并将在本文中介绍它。
附注:我的解决方案(部分)基于 mav-northwind 的解决方案,因此我不会解释如何使用 Win32 API 应用链接,因为在他的文章中已经解释得很清楚了。我使用他使用的相同的 SetSelectionStyle
方法,因为 IMHO,这是使任意文本成为链接的唯一方法。
使用控件
要使用该控件,您只需下载编译好的 DLL,并将其引用到您的项目中。之后,您应该会在工具箱中看到 AnyLinkRichTextBox 控件。只需将其拖放到您的窗体上,然后像使用普通 RichTextBox 一样使用它。
如果愿意,您也可以下载源代码并自己编译。
代码
下面我将展示并解释最相关的代码片段。在徒劳尝试简要介绍时,将不显示所有摘要和注释。
我将代码分成几部分,如果您想了解其中一部分的工作原理并忽略其他部分,可以从下面的列表中跳转到它
暂停和恢复绘制函数
[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();
}
这两个函数用于防止用户看到自动链接搜索过程中的任何闪烁或任何步骤(我稍后会解释该过程)。
我没有写,也不记得第一次在哪里看到这些函数,但当您需要对窗体进行任何类型的图形更新时,它们非常有用,并且希望用户只看到最终结果。:)
正则表达式
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
正则表达式用于识别文本中可能存在的链接。这里没什么好解释的,只是可以更改 customLinks
正则表达式以搜索其他格式的自定义链接,而不是默认的 Markdown 样式。
变量
private Dictionary<KeyValuePair<int, int>, string> hyperlinks = new Dictionary<KeyValuePair<int, int>, string>();
private Point pt;
private char[] spliters;
private int OldLength;
第一个变量 (hyperlinks
) 是一个 Dictionary,将用于存储每个自定义超链接作为 value
,并将链接文本的开始和长度数据作为 key
的 KeyValuePair
。
第二个变量 (pt
) 是一个点,将用于在用户点击链接时获取确切的位置。
第三个变量 (spliters
) 是一个字符数组,将存储自定义链接的分隔符字符。
最后一个变量 (OldLength
) 用于存储文本更改前的长度,并将用于MoveCustomLinks
方法。
构造函数
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;
}
这里有几点需要注意
- 我们必须在基类 Rich Text Box 的处理程序中添加 4 个事件。
- 我们必须设置自定义链接的分隔符。这些分隔符必须与
customLinks
正则表达式中指定的分隔符相同,否则某些事件将不会像预期那样工作。 - 我们必须将
base.DetectUrls
设置为false
,因为正如 mav-northwind 在他的文章中所述
引用"当
DetectUrls
属性设置为
true
时,如果您修改了链接旁边的文本,链接格式将丢失。"
- 如果我们希望控件检测链接,我们必须将
this.DetectUrls
设置为true
。
"但是等等!base.DetectUrls
和 this.DetectUrls
之间有什么区别?" - 您现在可能会问……
区别在于:前者是基类 (RichTextBox
) 的 DetectUrls
属性,后者是覆盖它的一个属性。
属性
[Browsable(true),
DefaultValue(false)]
public new bool DetectUrls { get; set; }
此属性定义在此处,以确保不会修改基类 (RichTextBox
) 的 DetectUrls
属性,但同时保留此控件不检测任何 URL 的可能性。
事件
我们在构造函数中添加了 4 个事件,还记得吗?现在,我将解释每一个。
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);
}
}
此事件用于使用户能够更改先前创建的自定义链接。
当创建自定义链接时,只会显示友好文本,而超链接将被隐藏(我将在解释 CheckCustomLinks
方法时专门讨论这一点)。因此,如果用户想修改超链接或友好文本,我们必须提供一种安全的方式来执行此操作。
RTBExCustomLinks_Protected
事件就是为此目的而设计的。
- 首先,它检查
DetectUrls
是否设置为 true。然后,它将找到链接文本的开头。 - 然后它检查我们是否在文本的末尾。如果不在,它将找到链接文本的结尾。
- 此时,它将已选中整个链接文本,因此现在,它将关闭保护。
- 最后,用原始格式替换链接文本,仅删除最后一个分隔符,例如 [链接文本](超链接文本
- 最后,它将从链接字典中删除该链接。
RTBExCustomLinks_MouseMove
private void RTBExCustomLinks_MouseMove(object sender, MouseEventArgs e)
{
pt = e.Location;
}
这是控件中最简单的事件。它只是监控鼠标在控件上方的位置,并将该数据存储到点 pt
中。
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!");
}
}
}
}
此事件将启动每种链接的正确过程。
首先,它会检查 DetectUrls
是否设置为 true,如果设置为 true,则事件将检查点击的链接文本是否与正则表达式匹配,直到找到正确的类型。
- 如果它是一个普通链接,即以普通协议(http://|https://|等)或www.开头。
- 如果它是一个类似邮件的链接,例如user@company.com 或 mailto:user@company.com
- (注意:为了使邮件链接生效,协议mailto:必须位于链接的开头。但是
mailLinks
正则表达式将任何格式为user@company.com 的文本(无论是否有协议)识别为邮件链接,因此用户拥有更多的自由。我们在这里处理了可能缺少协议的情况)。
- (注意:为了使邮件链接生效,协议mailto:必须位于链接的开头。但是
- 如果它是一个类似 IP 的链接,例如255.255.255.255
- (注意:为了使IP 链接生效,协议http://必须位于链接的开头。但是
IPLinks
正则表达式将任何格式为###.###.###.### 的文本识别为IP 链接,其中# = [0-255]。如果用户带协议写入,normalLinks
正则表达式将处理它。)
- (注意:为了使IP 链接生效,协议http://必须位于链接的开头。但是
- 最后,如果链接文本不匹配任何其他正则表达式,我们将尝试从友好文本中解析超链接。
- 首先,我们使用点
pt
从鼠标位置获取字符索引。 - 然后,我们根据该字符索引在
hyperlinks
字典中定位超链接。如何?很简单,我们使用IsInRange
方法(本文未显示,如果您好奇,请查看源代码)来检查字符索引是否位于友好文本的第一个和最后一个字符之间,针对每个hyperlinks key
。当此方法返回 true 时,我们将hyperlinks KeyValuePair
存储在局部变量linkClicked
中。 - 然后,我们将
hyperlinkClicked
变量设置为存储在linkClicked value
中的超链接。 - 最后,我们以
hyperlinkClicked
作为参数启动该过程。 - 如果该过程中有任何问题,我们将假定超链接无效,并通知用户。
- 首先,我们使用点
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();
}
}
这是整个过程的核心。在该事件中,我们识别所有链接以及其中可能存在的任何更改。
使用 TextChanged 事件,我们可以在运行时创建、修改或删除链接。
它将检查 DetectUrls
是否设置为 true,如果设置为 true,它将继续搜索链接。首先要做的是**暂停绘制**,原因如前所述。
然后,我们需要存储插入符号的位置,因此如果文本发生任何更改,我们可以将插入符号设置回之前的位置。这样用户就可以继续编写而不会有问题。
现在我将解释该事件的每个方法。
TextChanged 事件的方法
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;
}
此方法解析文本以查找匹配 customLinks
正则表达式的任何文本。
- 如果找到,它将把任何
Match
存储在局部变量(List)linksCustom
中,并为linksCustom
的每个元素,它将分隔友好文本和超链接。 - 接下来,它将为
hyperlinks
字典创建一个KeyValuePair
key
。 - 然后,它将检查创建的
key
是否已存在于字典hyperlinks
中,**或者**字典hyperlinks
中是否有任何条目的KeyValuePair
key
与创建的KeyValuePair
key
具有相同的key。如果其中任何一个条件为真,它将从字典中移除entry
并添加一个新条目。- 第二个条件是必需的,因为如果友好文本的长度(即字典中
KeyValuePair
key
的value)发生变化,但起始索引没有变化,我们仍然需要更新字典中的该数据。
- 第二个条件是必需的,因为如果友好文本的长度(即字典中
- 如果两个条件都为 false,它将仅添加新的
entry
。 - 现在,它将选择整个文本,例如 [友好文本](超链接) 并将其替换为友好文本。
- 当我们为 SelectedText 属性设置新值时,选定的文本变为
""
,因此我们需要重新选择友好文本。
- 当我们为 SelectedText 属性设置新值时,选定的文本变为
- 然后,我们使用
SetSelectionStyle
方法使其成为链接。最后,我们保护友好文本,以便能够识别用户何时想要更改/删除链接。 - 最后要做的是根据文本中的更改计算插入符号的新位置,并返回该位置。
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;
}
现在我们已经通过用友好文本替换自定义链接的完整格式来更改了文本的长度,因此有必要重新计算每个自定义链接的位置。
此方法执行此操作。
- 首先,它使用变量
OldLength
作为参考来计算替换引起的差异。- (注意:如果
OldLength
为零,则表示之前没有文本,现在有文本,因此我们不更改lengthDiff
的默认值。) - 我们仅在以下情况下进行任何重新计算:
- (注意:如果
- 存在任何自定义链接
lengthDiff
不为零,即文本已更改。
- 我们将每个
hyperlinks key
(此处每个key都是一个KeyValuePair
)的副本复制到一个列表(keysToUpdate
)。 - 然后,对于
keysToUpdate
中的每个项目,我们将重新计算该项目的key,但前提是文本中的更改是在项目**之前**的位置进行的(考虑到lengthDiff
)。 - 最后,我们从字典中移除过时的
entry
,并添加一个具有重新计算值的新条目,并将文本的当前长度设置为OldLength
值。
RemoveLinks
private void RemoveLinks()
{
this.SelectAll();
this.SelectionProtected = false;
this.SetSelectionStyle(CFM_LINK, 0);
}
此方法非常简单。它只是选择所有文本,移除可能存在的任何保护(即,如果存在任何自定义链接),并移除所有文本的链接效果。
这样,所有链接都消失了。所以现在,我们需要再次搜索链接。
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);
}
}
}
此方法解析文本以查找匹配 normalLinks
正则表达式的任何文本。
- 如果找到,它将把任何
Match
存储在局部变量(List)linksNormal
中。 - 对于
linksNormal
的每个元素,它会:- 选择文本
- 使用
SetSelectionStyle
方法设置链接效果。
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);
}
}
}
此方法解析文本以查找匹配 mailLinks
正则表达式的任何文本。
- 如果找到,它将把任何
Match
存储在局部变量(List)linksMail
中。 - 对于
linksMail
的每个元素,它会:- 选择文本
- 使用
SetSelectionStyle
方法设置链接效果。
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);
}
}
}
此方法解析文本以查找匹配 IPLinks
正则表达式的任何文本。
- 如果找到,它将把任何
Match
存储在局部变量(List)linksIP
中。 - 对于
linksIP
的每个元素,它会:- 选择文本
- 使用
SetSelectionStyle
方法设置链接效果。
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;
}
}
此方法将恢复自定义链接。
- 它会查看
hyperlinks
字典,并为每个entry
使用key
来选择链接文本。 - 然后它使用
SetSelectionStyle
方法设置链接效果。 - 最后,它将选择设置为受保护文本,以便我们知道用户何时想要修改/删除自定义链接。
结论
这就是我的解决方案。我知道可能还有更好的方法来做我在这里做的事情。毕竟,我不是 C# 编程专家。
而且我知道肯定有更好的方法来解释它!但我的英语不是母语,而且我比老师更擅长编程(略有)。
所以,如果您有任何评论或建议,或者如果您发现任何错误(英语语法/句法 || C# 语法/句法 :)),请告诉我!在下面的评论区发表评论,或在 GitHub 上的项目页面打开一个 issue。
历史
- 08/10/2014
初始版本。