简介:这是面向WPF开发者的一款HTML编辑组件,在WPF应用中实现富文本创建、编辑与显示,以XAML声明式界面配合WebBrowser控件嵌入HTML渲染能力,可满足文本格式化、表格、图片与链接编辑等需求。压缩包共180个文件,约708KB,其中.cs源码与.xaml界面文件构成主体,支撑阅读和二次开发;.dll与.exe为编译运行所需程序集和可执行入口,.png图片资源及.baml编译后的XAML亦包含在内,便于用Visual Studio直接打开工程、查看实现与修改定制。源代码来自网络共享,适合正在学习WPF、XAML或需要集成HTML编辑功能的开发者作为样例研究。已有615人学习浏览;包内SmithHtmlEditor实现覆盖编辑器对话框(表格、图片、超链接、颜色选择等)与样式控制,借助代码可掌握WebBrowser与JavaScript交互思路,理解如何把HTML编辑能力嵌入桌面程序并自定义工具栏和菜单。整体结构清晰,可作为自定义富文本编辑器的起点。
1. WPF 里做 HtmlEdit:与其自己写富文本,不如先学会桥接
做桌面客户端的时候,只要界面里出现“编辑一段富文本”这个需求,很多人第一反应是 TextBox 或者 RichTextBox,但客户要的是能贴网页片段、能插图片、能调字体颜色,这类需求在 WPF 原生控件里做起来非常痛苦。我后来在 XAML 窗口里用 WebBrowser 承载 HTML 编辑器,前端页面负责编辑,C# 通过桥接接口收发内容,这才把 WPF 和 HTML 真正串起来。这套 HtmlEdit 的做法适合那些不想引入大型跨平台框架、又被富文本格式折腾过的桌面开发者,尤其是已经在 WPF 项目里维护过 XAML 界面、但对前端只有基础认识的人。
2. 宿主与桥接:用 XAML 把 HTML 编辑器请进窗口,并让 JS 和 C# 互相调用
2.1 选型:WebBrowser、WebView2 还是 CefSharp
把 HTML 编辑器放进 WPF,第一步不是写编辑器,而是选定承载它的容器。WPF 自带的 WebBrowser 是基于系统 IE 内核的 ActiveX 控件,能在 XAML 里直接用,不需要额外安装运行时,发布时也不用打包一堆依赖,这是它最大的优势。缺点是内核版本跟着系统走,如果客户机器是老系统,渲染表现会和开发机不一样。这个方案最适合做内部工具类项目,尤其是部署环境可控、没有复杂动画效果需求的场景。
如果客户明确要求现代渲染效果,比如需要支持较新的 CSS 特性,我一般会考虑基于 Chromium 的 WebView2。它以独立的运行时组件方式分发,API 设计和 WebBrowser 接近,但初始化逻辑、导航事件、JS 互操作方式都有差异。CefSharp 是另一种常见选择,集成包体积较大,离线部署比较可控,不过升级内核要重新编译整个依赖链。就这套 HtmlEdit 资源来说,核心交互逻辑是“前端编辑器页 + C# 桥接口”,宿主换哪一个都不需要重写业务代码,所以我更建议先用 WebBrowser 跑通闭环,再决定要不要上重型内核。
2.2 在 XAML 里搭编辑器宿主框架
先把最基础的窗体搭出来。我的做法是顶部放一个 ToolBar 作为格式工具栏,下面放 WebBrowser 承载 editor 页面,这样格式按钮和编辑区分离,后续扩展也不会互相干扰。
<DockPanel> <ToolBarTray DockPanel.Dock="Top"> <ToolBar> <Button Content="B" Click="OnBold_Click" ToolTip="加粗" /> <Button Content="I" Click="OnItalic_Click" ToolTip="斜体" /> <Button Content="插入图片" Click="OnInsertImage_Click" /> <Button Content="读取内容" Click="OnGetHtml_Click" /> </ToolBar> </ToolBarTray> <WebBrowser x:Name="HtmlEditorHost" /> </DockPanel>要注意,WPF 的 WebBrowser 并不是真正在 WPF 内核里渲染,它内部通过 WindowsFormsHost 承载了 ActiveX 控件,所以 WebBrowser 在 XAML 里的层级、尺寸行为都和普通控件不太一样。实际使用中,WebBrowser 的宽度、高度、Dock 行为基本符合预期,但不要指望它能像原生控件那样参与复杂的布局变换。ToolBar 按钮别直接写大量逻辑,我习惯把它们看成“遥控器”,真正干活的是被桥接的 JS 函数。
2.3 前端模板:把编辑器页面和桥接接口放在一起
编辑器页面我建议放在单独目录,例如 EditorTemplate/index.html,这样 Navigate 路径好维护,以后替换编辑器版本也方便。页面里引入 Quill 作为编辑内核,然后暴露三个桥接口给 C# 调用:获取内容、重置内容、插入图片。核心代码大概是这样的。
<script> var editor = new Quill('#editor', { modules: { toolbar: true }, theme: 'snow' }); window.__getContent = function () { return editor.root.innerHTML; }; window.__resetContent = function (html) { editor.root.innerHTML = html || ''; }; window.__insertImage = function (dataUrl) { var range = editor.getSelection(true); editor.insertEmbed(range.index, 'image', dataUrl, 'user'); }; window.__format = function (name) { editor.format(name, true, 'user'); }; </script>这里解释一下接口设计:__getContent直接返回editor.root.innerHTML,它拿到的是编辑器内部 DOM 的完整 HTML,比用 Quill 的getSemanticHTML()更贴近用户所见。__resetContent用来回显数据库里的旧内容,直接对root赋值能保证回显后样式一致。__insertImage用的是 Quill 的insertEmbed,通过getSelection(true)获取当前光标位置,图片会插在光标处,而不是总跑到末尾。__format则给 WPF 侧的统一格式操作留了一个入口。
这里有一个常见的误解:有人会在 C# 端拼document.execCommand('bold')来加粗,但 Quill 接管了编辑器 DOM 后,execCommand 的操作对象和 Quill 内部状态可能不一致,最后导致光标错乱、格式状态不同步。工具栏操作应该通过editor.format这类 Quill API 来做,而不是直接碰浏览器原生命令。
3. 内容与文件操作:SetHtml、GetHtml、插入图片和保存的一条完整链路
3.1 初始化:页面加载完成前不要碰编辑器
WebBrowser 的导航是异步的,窗口 Loaded 之后立即调用HtmlEditorHost.Document,大概率拿到的还是 null 或者空白页。我一般会在LoadCompleted事件里做初始化,并在页面加载完成后设置一个就绪标志,后续所有桥接调用都先检查这个标志。
private bool _editorReady; private Uri _editorUri; private void MainWindow_Loaded(object sender, RoutedEventArgs e) { _editorUri = new Uri(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "EditorTemplate", "index.html")); HtmlEditorHost.Navigate(_editorUri); } private void HtmlEditorHost_LoadCompleted(object sender, NavigationEventArgs e) { if (e.Uri == _editorUri) { _editorReady = true; } }这里的关键点是判断e.Uri == _editorUri。如果页面里有 iframe 或者异步加载子资源,LoadCompleted可能被触发多次,不判断来源会误以为编辑器已经就绪。实际项目里,如果编辑器页面跳转到了其他地址,这个标志也应该重新置为 false,否则后续调用会打到错误页面上。需要特别注意的是,Document属性每次访问都重新包装 COM 对象,不要在构造函数里缓存它,要用的时候再取。
3.2 回显旧内容:SetHtml 的转义顺序决定成败
把数据库里的 HTML 填充到编辑器,我见过不少翻车现场。最直接的方式是拼一段 JS 调用__resetContent,但 HTML 字符串里的反斜杠、单引号、换行、甚至 U+2028 这种不可见字符都会让 JS 解析失败。我的做法是先把 C# 字符串转义成“可安全放进 JS 单引号字符串”的形态,再交给 InvokeScript。
private string EscapeJsString(string text) { return text .Replace("\\", "\\\\") .Replace("'", "\\'") .Replace("\r\n", "\n") .Replace("\r", "\n") .Replace("\n", "\\n") .Replace("\u2028", "\\u2028") .Replace("\u2029", "\\u2029"); } public void SetHtml(string html) { if (!_editorReady) return; var escaped = EscapeJsString(html); var js = $"__resetContent('{escaped}')"; HtmlEditorHost.Document.InvokeScript("eval", new object[] { js }); }转义顺序是重点:必须先处理反斜杠,再处理单引号,最后处理换行。如果先转义单引号,原文本里的\'会被转成\\',在 JS 里反而产生错误的转义语义。换行转成\\n而不是直接删掉,是为了保留原文的段落分隔。U+2028 和 U+2029 是 JS 里的行分隔符,直接放进字符串字面量会导致代码被截断,很多人乱码问题其实就出在这里。把这段封装成一个独立方法,不要在业务代码里每次手写 Replace 链。
3.3 获取编辑器内容:GetHtml 的返回值处理
获取内容要容易很多,仍然走eval调__getContent(),返回值强转成 string 就行。但因为 WebBrowser 的 COM 桥接偶尔会返回DBNull或其他类型,直接 as string 可能出现空引用。我习惯先判断类型再返回。
public string GetHtml() { if (!_editorReady) return string.Empty; var result = HtmlEditorHost.Document.InvokeScript( "eval", new object[] { "__getContent()" }); if (result is string html) { return html; } return string.Empty; }这里返回的是编辑器内部 HTML,包含 Quill 生成的 class、data 属性、内联样式,所以它适合直接回显、直接保存,但不适合直接展示到其他页面,因为样式依赖的是 Quill 自己的 CSS。如果要把内容发给外部展示端,必须在保存时做一次清洗和样式内联,这个我放到最后一节说。
3.4 接入工具栏:用桥接口统一格式操作
前面 XAML 里的加粗按钮、斜体按钮,Click 处理函数只需要一行桥接。不要在每个按钮里写不同的 JS 拼接逻辑,统一走__format接口,后面加下划线、删除线、标题字号都只是改参数的问题。
private void OnBold_Click(object sender, RoutedEventArgs e) { CallEditor("__format('bold')"); } private void OnItalic_Click(object sender, RoutedEventArgs e) { CallEditor("__format('italic')"); } private void CallEditor(string script) { if (!_editorReady) return; HtmlEditorHost.Document.InvokeScript("eval", new object[] { script }); }参数说明:__format('bold')里的 bold 是 Quill 的格式名,不是 CSS 属性名。加粗是 bold,斜体是 italic,标题是 header,每个格式名对应 Quill 内部的一套处理逻辑。如果你想做“切换”而不是“强制开启”,需要把 JS 接口改成先editor.format(name, false)再重新format(name, true)做 toggle,但当前这个简化版本只负责开启。
3.5 插入图片:本地路径不能直接用
图片插入是富文本编辑器里最常见的坑。如果直接把本地图片路径填到src里,比如src="D:\photos\a.png",保存的 HTML 拿到别的机器上就裂了。即使部署在同一台机器,路径里有空格或中文字符也会出问题。我的做法是打开文件后读取字节,转成 Base64 的 Data URL,再传给前端接口。
private void OnInsertImage_Click(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog { Filter = "图片文件|*.png;*.jpg;*.jpeg;*.bmp;*.gif" }; if (dialog.ShowDialog() != true) return; var info = new FileInfo(dialog.FileName); if (info.Length > 2 * 1024 * 1024) { MessageBox.Show("图片超过 2MB,请压缩后再插入"); return; } var bytes = File.ReadAllBytes(dialog.FileName); var base64 = Convert.ToBase64String(bytes); var mime = GetMimeType(info.Extension); var dataUrl = $"data:{mime};base64,{base64}"; HtmlEditorHost.Document.InvokeScript( "eval", new object[] { $"__insertImage('{dataUrl}')" }); } private string GetMimeType(string ext) { switch (ext.ToLower()) { case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".bmp": return "image/bmp"; case ".gif": return "image/gif"; default: return "application/octet-stream"; } }Base64 会让图片体积膨胀约 33%,所以我在打开文件时就限制 2MB,这个阈值对内部系统足够,也避免 Eval 传超大字符串时踩到 IE 内核的字符串长度限制。Data URL 字符串里只包含字母、数字、+、/、=,不会包含单引号,所以拼进 JS 字符串时不需要额外转义。如果后续改成“真实上传到服务器再插 src”,那保存的 HTML 会小很多,但离线环境不适用。
3.6 保存与加载:UTF-8 无 BOM 是默认约定
保存内容时,很多人直接File.WriteAllText,结果存出来的 HTML 在部分解析器里第一行多个不可见字符,或者浏览器里显示一个空字符,这就是 BOM 问题。我保存编辑器内容时统一用无 BOM 的 UTF-8。
var html = GetHtml(); File.WriteAllText(savePath, html, new UTF8Encoding(false));加载时也要注意,File.ReadAllText会自动识别 BOM,但如果文件是 GB2312 编码,读出来就是乱码。这时要么在保存端就锁定 UTF-8,要么读取时先检测编码。就这套 HtmlEdit 流程来说,最终落库的是 HTML 文本,我建议保存时只固定一种编码,不要给用户“另存为其他编码”的选项,否则回显时编码判断会变成玄学问题。
4. HtmlEdit 避坑:五个高频问题,现象、原因和解决办法
4.1 Document 刚创建就是 null,InvokeScript 抛 COMException
现象:窗体加载后,第一次点“读取内容”按钮,HtmlEditorHost.Document.InvokeScript抛 COMException,或者返回结果一直是 null。
原因:WebBrowser 的 Document 对象在页面导航完成前不存在,甚至可能处于about:blank状态。窗体 Loaded 和页面 LoadCompleted 是两个完全不同的事件,Loaded 只代表窗体准备完成,不代表编辑器的 JS 接口已经挂上。
解决:用布尔标志位管理就绪状态,所有桥接操作统一走一个入口,未就绪时直接返回或提示。
private void CallEditor(string script) { if (!_editorReady || HtmlEditorHost.Document == null) { MessageBox.Show("编辑器尚未加载完成,请稍后再试"); return; } HtmlEditorHost.Document.InvokeScript("eval", new object[] { script }); }从那以后我做任何 WPF 编辑器控件,都会在公共调用入口先检查这一层,不在每个按钮里重复判断。
4.2 LoadCompleted 被触发多次,初始化代码重复执行
现象:_editorReady被重复设为 true 还不致命,但如果在 LoadCompleted 里做编辑器初始化、绑定事件、加载默认内容,就会发现初始化逻辑执行了两三次,编辑器内容被重置。
原因:WebBrowser 的 LoadCompleted 在页面里的 iframe、frame 或动态加载的子文档完成时也会触发。编辑器页面只要引用了图片、CSS、JS 以外的子文档,就可能产生额外事件。
解决:只认主文档的 Uri,其他来源一律忽略。
private void HtmlEditorHost_LoadCompleted(object sender, NavigationEventArgs e) { if (e.Uri != _editorUri) return; _editorReady = true; }如果页面存在多个 iframe,这段代码仍然可能触发多次,但至少不会把所有子文档都当成主页面。需要再精确,可以配合一个计时器,延迟几十毫秒后校验HtmlEditorHost.Document是否存在。
4.3 大段 HTML 包含单引号和换行,SetHtml 显示不完整
现象:回显一段数据库里保存的旧 HTML,结果编辑器里只显示了前半段,后面内容被截断,或者整个内容直接消失。
原因:HTML 字符串拼进 JS 单引号字符串时没有完整转义。原文里的单引号把 JS 字符串提前关闭了,换行又导致 JS 解析器认为语句结束。另一个更容易被忽略的是 U+2028 行分隔符,JS 解析器遇到它会把字符串拆成两行,后续内容全部失效。
解决:用前面写的EscapeJsString,并且转义顺序固定为:反斜杠、单引号、换行、Unicode 行分隔符。这个函数应该属于编辑器控件的内部工具类,不暴露给业务层。
var bad = "<p>it's a test</p>\n<p>next line</p>"; var escaped = EscapeJsString(bad); var js = $"__resetContent('{escaped}')";注意,\\n在 JavaScript 字符串里是换行符,但在 HTML 的段落结构里它并不产生可见换行。所以这段转义只为保证 JS 字符串完整,不要指望它改变 HTML 排版。
4.4 在编辑器里按 Tab 键不缩进,焦点直接跳到下一个控件
现象:编辑器里输入几个字,按下 Tab,光标没动,焦点跳到了窗体的下一个按钮上,编辑器里的内容被迫中断。
原因:WPF 的键盘路由默认把 Tab 当作焦点导航键,WebBrowser 内部的 JS 事件虽然能收到 keydown,但没有阻止默认行为,Quill 也不会主动拦截 Tab。
解决:在编辑器页面的 JS 里捕获 keydown,Tab 按下时preventDefault,再用 Quill 的insertText插入\t。
editor.root.addEventListener('keydown', function (e) { if (e.key === 'Tab') { e.preventDefault(); var range = editor.getSelection(true); editor.insertText(range.index, '\t', 'user'); } });这里的'\t'是制表符,Quill 会把它当作普通文本存进内容里。如果要模拟代码编辑器的块缩进,还需要扩展成多行同时缩进,但作为富文本编辑器,插入制表符已经够用。
4.5 保存的 HTML 换机器打开,图片全部裂图
现象:在本机保存的 HTML 文档,复制到另一台电脑,图片全部显示为破碎图标。
原因:编辑时插入的是本地绝对路径,比如D:\photos\1.png,保存的 HTML 里src就是这个路径。换机器后路径不存在,自然加载不了。
解决:插入图片时强制转 Base64。这样保存的 HTML 自包含,不需要复制图片目录,也不会因为路径变化而裂图。代价是文件体积变大,所以要在选图时就限制大小,而不是等保存时才提示。
if (info.Length > 2 * 1024 * 1024) { MessageBox.Show("图片超过 2MB,为了保存文档稳定性,请压缩后重试"); return; }如果是大型图片,比如截图工具产生的 PNG 动辄几 MB,这个限制会挡住用户。我的替代方案是:本地图片时接受 Base64,但自动压缩到合理尺寸后再转 Data URL;上传模式则走独立接口,不在编辑器里拼 base64。
5. 进阶:给编辑器输出做一层白名单清洗,别信用户在编辑器里的任何输入
5.1 用白名单过滤危险标签和事件属性
Quill 默认的编辑器输出已经过滤掉了很多危险内容,但用户可能直接从网页复制粘贴内容,尤其是从邮件客户端、在线文档里粘贴时,原始 HTML 里可能夹杂script、iframe、内联事件。把这样的 HTML 直接存进数据库,下次回显时如果宿主页面权限过高,就可能出现问题。
我一般会在保存前做一次白名单清洗,只保留富文本编辑器真正需要的标签,其余全部移除。
private static readonly HashSet<string> AllowedTags = new HashSet<string> { "p", "br", "strong", "em", "u", "s", "ol", "ul", "li", "blockquote", "a", "img", "span", "h1", "h2", "h3", "code", "pre", "hr" }; private static readonly HashSet<string> AllowedSchemes = new HashSet<string> { "http", "https", "mailto", "tel", "data" }; public static string CleanHtml(string html) { if (string.IsNullOrEmpty(html)) return string.Empty; // 去掉 script、iframe、object 标签体 html = Regex.Replace(html, "<script\\b[^<]*?<\\/script\\s*>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); html = Regex.Replace(html, "<iframe\\b[^<]*?<\\/iframe\\s*>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); html = Regex.Replace(html, "<object\\b[^<]*?<\\/object\\s*>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); // 去掉所有 on* 事件属性 html = Regex.Replace(html, "(<[^>]+)\\s+on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)", "$1", RegexOptions.IgnoreCase); // 校验 a 标签的链接协议 html = Regex.Replace(html, "(href\\s*=\\s*)(\"[^\"]*\"|'[^']*'|[^\\s>]+)", m => { var rawUrl = m.Groups[2].Value.Trim('\'', '\"'); var match = Regex.Match(rawUrl, "^([a-zA-Z][a-zA-Z0-9+.-]*):"); var scheme = match.Success ? match.Groups[1].Value.ToLower() : ""; if (AllowedSchemes.Contains(scheme)) { return m.Value; } return m.Groups[1].Value + "\"#\""; }, RegexOptions.IgnoreCase); return html; }这段代码是“够用”级别的过滤,不是完整 HTML 解析器。它能挡住最常见的 script 和 iframe,清除 onclick 这类内联事件,并且把href="javascript:..."这种危险链接替换为#。注意,正则处理 HTML 只适合内部工具的兜底场景,如果是面向外部用户的产品,建议换用 DOM 解析库,先解析成节点树,再做白名单遍历。
5.2 保存前的强制校验流程
我现在的保存流程不是直接File.WriteAllText,而是先CleanHtml,再校验一次是否还有漏网标签,确认干净后才落盘。
var rawContent = GetHtml(); var cleanContent = CleanHtml(rawContent); if (Regex.IsMatch(cleanContent, "<script|<iframe|<object", RegexOptions.IgnoreCase)) { MessageBox.Show("内容中含有被拦截的标签,已阻止保存"); return; } File.WriteAllText(savePath, cleanContent, new UTF8Encoding(false));这里二次校验其实是给CleanHtml兜底,万一正则没匹配到嵌套写法,至少保存前能拦住。从那以后,我每次交付带编辑器功能的任务,都会强制走一遍“读取 → 清洗 → 落盘 → 重新读取回显”的流程,任何一步内容对不上就停下来查是清洗规则写错还是桥接层丢了内容。这套流程虽然朴素,但比在 UI 上反复调试稳定得多,希望帮到你。
本文还有配套的精品资源,点击获取