用户在您的桌面应用中编写邮件模板、工单回复或报告备注。这些文本必须保存为 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 格式加载和保存文档:

工具包控件可加载和保存的格式
WPFRichTextBoxRTF、XAML、纯文本
WinFormsRichTextBoxRTF、纯文本
WinUI 3RichEditBoxRTF、纯文本
Avalonia UIRichTextEditor,Avalonia Pro 的一部分RTF、DOCX、XAML、Markdown、纯文本;可导入 HTML,可导出 PDF

选择 JavaScript 编辑器 

为 Web 编写的编辑器都是围绕 HTML 构建的,而 WebView 可以承载其中任何一个:

编辑器许可证托管方式
Quill 2BSD-3-Clause自托管
TiptapMIT自托管
CKEditor 5GPL 2 或更高版本,或商业许可自托管或 CKEditor 的 CDN
TinyMCEGPL 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 中,它还会把每个空格写成 &nbsp;,因此 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 编辑器。它的文件随应用附带,词典也可以随应用附带,因此编辑器无需网络连接即可工作。

完整源代码可在示例仓库中获取。要在您自己的应用中试一试,请获取免费评估许可证:

Spinner

发送中。。。

抱歉,发送中断

请再次尝试,如果问题仍然存在,请联系我们 info@teamdev.com.

阅读并同意条款以继续。

您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。