用户在您的桌面应用中编写邮件模板、工单回复或报告备注。这些文本必须保存为 HTML,因为邮件服务器、服务台系统或网站需要的就是 HTML。
WPF 和 WinForms 中的富文本控件读写的是 RTF(Rich Text Format),而不是 HTML。在 Avalonia UI 中,富文本控件是 Avalonia Pro 的一部分。
本文将构建一个以 HTML 保存文档的 Avalonia 富文本编辑器。该应用通过原生工具栏设置文本格式,可以打开和保存 HTML、检查拼写,并导出为 PDF。编辑器是 Quill,一个 JavaScript 编辑器,它运行在 DotNetBrowser(一个基于 Chromium 的 .NET WebView)中。DotNetBrowser 随应用附带自己的 Chromium,因此每位用户都在同一个 Chromium 版本中运行编辑器。
同样的 DotNetBrowser 代码也可以用来实现 WPF HTML 编辑器或 WinForms HTML 编辑器,在 WinUI 3 中同样可以使用。您只需重写 UI 代码:窗口、工具栏和上下文菜单。
我们使用 DotNetBrowser 4.4.0、Avalonia UI 12 和 .NET 10 在 Windows 11 上构建并运行了该示例。DotNetBrowser for Avalonia UI 也支持 macOS 和 Linux,但我们没有在这些平台上测试该示例。完整示例位于示例仓库中。
在编辑器中输入文字,并通过 Avalonia 工具栏设置格式。
准备工作
- .NET 10 SDK。
- 一个 DotNetBrowser 许可证密钥。评估密钥可免费使用 30 天。
原生富文本控件使用 RTF
.NET 桌面工具包的富文本控件主要以 RTF 格式加载和保存文档:
| 工具包 | 控件 | 可加载和保存的格式 |
|---|---|---|
| WPF | RichTextBox | RTF、XAML、纯文本 |
| WinForms | RichTextBox | RTF、纯文本 |
| WinUI 3 | RichEditBox | RTF、纯文本 |
| Avalonia UI | RichTextEditor,Avalonia Pro 的一部分 | RTF、DOCX、XAML、Markdown、纯文本;可导入 HTML,可导出 PDF |
选择 JavaScript 编辑器
为 Web 编写的编辑器都是围绕 HTML 构建的,而 WebView 可以承载其中任何一个:
| 编辑器 | 许可证 | 托管方式 |
|---|---|---|
| Quill 2 | BSD-3-Clause | 自托管 |
| Tiptap | MIT | 自托管 |
| CKEditor 5 | GPL 2 或更高版本,或商业许可 | 自托管或 CKEditor 的 CDN |
| TinyMCE | GPL 2 或更高版本,或商业许可 | 自托管或 Tiny Cloud |
我们使用 Quill 2.0.3:它的许可证很宽松,而且只包含一个脚本和一个样式表,不需要构建步骤。其他编辑器的集成方式相同:提供编辑器的文件,从 .NET 调用它的 API,并监听它的事件。
随应用附带编辑器
编辑器页面 editor.html 和 Quill 的文件位于项目的 Editor 文件夹中,并作为嵌入资源编译到应用中。如果从 CDN 加载它们,编辑器就会依赖网络和第三方服务器。
为了向 Chromium 提供这些文件,应用在创建 Engine(即启动 Chromium 的对象)时注册了自己的 URL 方案 app://:
engine = EngineFactory.Create(new EngineOptions.Builder
{
RenderingMode = RenderingMode.HardwareAccelerated,
UserDataDirectory = UserData,
Schemes = { { EditorScheme.Scheme, new EditorScheme() } }
}.Build());
DotNetBrowser 会将每个 app:// 请求发送给 EditorScheme,后者按文件名找到嵌入资源,并将其作为响应返回。以下是 EditorScheme.Handle() 的核心部分:
UrlRequestJob job =
parameters.Network.CreateUrlRequestJob(parameters.UrlRequest, options);
// ... copy the resource into bytes
job.Write(bytes.ToArray());
job.Complete();
return InterceptRequestResponse.Intercept(job);
这里的 options 包含状态码和 Content-Type 标头。页面使用相对 URL 加载 Quill 的文件,因此它们也由同一个处理程序提供。
Engine 选项还设置了渲染模式。示例使用硬件加速模式,它将页面绘制到一个原生窗口中。如果您自己的控件必须覆盖在编辑器之上,请使用 RenderingMode.OffScreen。
编辑器页面
页面的脚本 editor.js 创建不带自身工具栏的 Quill,因为应用有原生工具栏。它将几个供 .NET 使用的函数放入 window.editor,并将每次更改报告给 window.host,后者是一个 .NET 对象,我们将在下一节中添加:
const quill = new Quill('#editor', {
theme: 'snow',
modules: { toolbar: false },
formats: ['bold', 'italic', 'underline', 'header', 'list', 'link',
'color', 'image'],
});
window.editor = {
getHtml() {
return quill.getSemanticHTML().replaceAll(' ', ' ');
},
setHtml(html) {
quill.setContents(quill.clipboard.convert({ html }), 'silent');
quill.history.clear();
},
// Turns a format on for the selection, or off if it is already on.
toggle(name, value = true) {
const current = quill.getFormat()[name];
quill.format(name, current === value ? false : value, 'user');
}
};
quill.on('text-change', () => {
const words = quill.getText().split(/\s+/).filter(w => w.length > 0);
window.host.TextChanged(words.length);
});
getSemanticHTML() 返回 <h2>、<strong> 和 <ul> 这样的普通标签。在 Quill 2.0.3 中,它还会把每个空格写成 ,因此 getHtml() 会将它们还原为空格。
setHtml() 使用 Quill 的剪贴板模块转换 HTML,该模块也负责处理粘贴。'silent' 参数告诉 Quill 不要触发 text-change,因此加载文档不算作一次编辑。清除撤销历史记录可以防止 Ctrl+Z 撤销这次加载。
Quill 只保留 formats 列表中的格式,并丢弃其余格式,例如字体和字号。
打开的 HTML 文件可能来自任何地方,因此页面的内容安全策略(Content Security Policy)只允许应用自己的文件,远程图片不会加载。如需允许加载远程图片,请将 https: 添加到该策略的 img-src 中。
连接编辑器与 .NET
应用通过 Engine 创建一个 Browser,并将其显示在窗口的 BrowserView(DotNetBrowser 面向 Avalonia UI 的控件)中。如果您的应用有多个编辑器,请通过同一个 Engine 创建它们的所有 Browser,因为每个 Engine 都会启动自己的 Chromium 主进程。
host 是 EditorHost 的一个实例,后者是一个普通的类,其公共方法 TextChanged(int words) 会触发它的 Changed 事件。应用使用 InjectJsHandler 将 host 注入为 window.host,DotNetBrowser 会在页面的脚本运行之前调用该处理程序:
browser.InjectJsHandler = new Handler<InjectJsParameters>(p =>
{
if (!p.Frame.IsMain)
{
return;
}
IJsObject window = p.Frame.ExecuteJavaScript<IJsObject>("window").Result;
window.Properties["host"] = host;
});
host.Changed += words => Dispatcher.UIThread.Post(
() => Status.Text = $"{words} words");
JavaScript 在后台线程上调用 TextChanged(),因此 Changed 的订阅者会将更新发送到 UI 线程。在 WPF 中,对应的是 Dispatcher.BeginInvoke();在 WinForms 中是 Control.BeginInvoke();在 WinUI 3 中是 DispatcherQueue.TryEnqueue()。
为了调用页面中的函数,应用以 IJsObject(对 JavaScript 对象的 .NET 引用)的形式获取 window.editor,并使用 Invoke() 调用这些函数。Welcome 是示例文档:
editor = await browser.MainFrame.ExecuteJavaScript<IJsObject>("window.editor");
editor.Invoke("setHtml", Welcome);
HTML 作为参数传递给 JavaScript,因此其中的引号和反斜杠会原样到达 Quill。如果改为用 HTML 拼接脚本文本,例如 $"editor.setHtml('{html}')",文档中的第一个单引号就会导致脚本出错。
Save 按钮以同样的方式读取文档,使用的是 editor.Invoke<string>("getHtml")。
通过原生工具栏设置格式
每个格式按钮都会调用应用的 Format() 方法,该方法使用某个 Quill 格式调用 toggle(),例如 "bold",或者 "header" 加上标题级别:
private void Format(string name, object? value = null)
{
editor?.Invoke("toggle", name, value ?? true);
BrowserView.Focus();
}
点击工具栏按钮之后,需要将键盘焦点交还给 BrowserView,否则用户按下的键不会到达编辑器。
检查拼写
Chromium 会检查编辑器中的拼写,并用红色下划线标出拼写错误的单词。它将拼写检查设置保存在配置文件(Profile)中,一个配置文件包含一位用户的数据和设置。在 Windows 上,新的配置文件没有拼写检查语言,因此应用会添加一种语言:
ISpellChecker spellChecker = engine.Profiles.Default.SpellChecker;
try
{
await spellChecker.Languages.Add(Language.EnglishUs);
}
catch (LanguageNotAvailableException)
{
Status.Text = "Spell checking is off: no dictionary for English (US).";
}
在 Windows 和 Linux 上,Chromium 会在应用首次添加该语言时下载词典;在 macOS 上,它使用系统的词典。Chromium 将下载的词典保存在用户数据目录中,与用户添加到自定义词典中的单词放在一起。正因如此,应用设置了固定的 UserDataDirectory:否则,DotNetBrowser 会使用一个新的临时目录,每次启动都要重新下载。
如果首次启动时没有网络连接,下载会失败,Add() 会抛出 LanguageNotAvailableException。为避免这种情况,请随应用附带词典文件,并在创建 Engine 之前将其复制到用户数据目录中。示例的 README 说明了具体做法。
如果您的应用有自己的用户帐户,请使用 engine.Profiles.Create(name) 为每个帐户创建一个配置文件,并使用 profile.CreateBrowser() 创建 Browser。每个配置文件都有自己的拼写检查语言和自定义词典,因此也要将语言添加到每个配置文件的 SpellChecker 中。
上下文菜单中的拼写建议
在编辑器中,BrowserView 的默认上下文菜单提供 Copy 和 Paste。为了添加拼写建议,应用在 ShowContextMenuHandler 中构建自己的菜单。该处理程序的参数包含一个 SpellCheckMenu,其中有拼写错误的单词和词典建议。应用为每条建议添加一个菜单项,另外添加一个用于将单词加入自定义词典的菜单项:
foreach (string suggestion in spelling!.DictionarySuggestions)
{
menu.Items.Add(Item(suggestion,
() => browser.ReplaceMisspelledWord(suggestion)));
}
menu.Items.Add(Item("Add to dictionary",
() => profile.SpellChecker.CustomDictionary.Add(word)));
自定义菜单会完全取代默认菜单,因此它还需要包含常用的编辑命令。应用添加了 Cut、Copy 和 Paste,每个命令都会在页面中执行一个 EditorCommand,例如 browser.MainFrame.Execute(EditorCommand.Copy())。

针对拼写错误的单词给出的建议。
导出为 PDF
Export PDF 按钮会对页面调用 Print()。RequestPrintHandler 跳过打印对话框,PrintHtmlContentHandler 将打印作业发送到 Chromium 的内置 PDF 打印机,由它将作业写入用户选择的文件:
PdfPrinter<PdfPrinter.IHtmlSettings> pdf = p.Printers.Pdf;
pdf.PrintJob.Settings.Apply(s =>
{
s.PdfFilePath = path;
s.PrintingHeaderFooterEnabled = false;
});
// ...
return PrintHtmlContentResponse.Print(pdf);
如果不设置 PrintingHeaderFooterEnabled = false,每一页都会带有页眉和页脚,其中包含日期和时间、页面标题、app:// URL 以及页码。
运行编辑器
将您的许可证密钥放入 DotNetBrowser-Examples 仓库根文件夹中的 dotnetbrowser.license 文件。
在 Windows 上,请使用未以管理员身份运行的终端。默认情况下,Chromium 在以管理员权限运行时不会启动,示例会直接退出而不显示窗口。如果您需要管理员权限,请参阅故障排除指南。
然后在 csharp/rich-text-editor 文件夹中运行示例:
dotnet run --project RichTextEditor
总结
最终结果是一个位于原生 Avalonia UI 窗口中的 HTML 编辑器。它的文件随应用附带,词典也可以随应用附带,因此编辑器无需网络连接即可工作。
完整源代码可在示例仓库中获取。要在您自己的应用中试一试,请获取免费评估许可证:
发送中。。。
您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。
