资讯详情 WPF中嵌入HTML编辑器:WebBrowser与C#桥接实现富文本编辑
📅 2026/10/11 12:46:29
简介这是面向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.DockTop ToolBar Button ContentB ClickOnBold_Click ToolTip加粗 / Button ContentI ClickOnItalic_Click ToolTip斜体 / Button Content插入图片 ClickOnInsertImage_Click / Button Content读取内容 ClickOnGetHtml_Click / /ToolBar /ToolBarTray WebBrowser x:NameHtmlEditorHost / /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 字符串里的反斜杠、单引号、换行、甚至 U2028 这种不可见字符都会让 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而不是直接删掉是为了保留原文的段落分隔。U2028 和 U2029 是 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里比如srcD:\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 刚创建就是 nullInvokeScript 抛 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 解析器认为语句结束。另一个更容易被忽略的是 U2028 行分隔符JS 解析器遇到它会把字符串拆成两行后续内容全部失效。解决用前面写的EscapeJsString并且转义顺序固定为反斜杠、单引号、换行、Unicode 行分隔符。这个函数应该属于编辑器控件的内部工具类不暴露给业务层。var bad pits a test/p\npnext 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 里捕获 keydownTab 按下时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 HashSetstring AllowedTags new HashSetstring { p, br, strong, em, u, s, ol, ul, li, blockquote, a, img, span, h1, h2, h3, code, pre, hr }; private static readonly HashSetstring AllowedSchemes new HashSetstring { 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, ([^])\\son[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 这类内联事件并且把hrefjavascript:...这种危险链接替换为#。注意正则处理 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 上反复调试稳定得多希望帮到你。本文还有配套的精品资源点击获取