首页 / 资讯中心 / 文章详情

WPF富文本编辑器开发:从FlowDocument到RichTextBox的全面拆解

WPF富文本编辑器开发:从FlowDocument到RichTextBox的全面拆解 ★ FEATURED ARTICLE
简介资源为基于WPF的富文本编辑器开源代码与Demo面向需要在Windows桌面应用中实现类似Word编辑能力的.NET开发者。项目采用XAML与C#实现覆盖文本输入、删除、复制粘贴、查找替换等基本操作同时提供HTML源码查看编辑、打印、导出文本、插入图片与表格等进阶功能代码结构清晰适合入门学习与二次定制。压缩包共289个文件以76个cs源码、10个xaml界面设计、15个baml编译视图为主配以57个gif、54个png用于预览图标与示例表情另含9个dll及可执行文件、解决方案等整体体积仅753KB便于快速下载阅读。目前已有2874人学习下载源码区分HtmlEditor核心组件库与HtmlEditor.Demo演示应用开发者可结合MainWindow等模块理解编辑框交互、对话框封装与后台逻辑协作。通过研读本项目可以掌握WPF富文本处理流程、图片与表格的插入机制、HTML与可视化视图同步等关键技巧为构建功能完善的自定义文本编辑器提供可靠起点。1. WPF富文本编辑器造 Word 轮子之前先把 FlowDocument 吃透很多人一听「仿 Word 的富文本编辑器」第一反应是自己画矩形、算光标、管选区——那就掉进坑里了。这个开源 demo 最反直觉的一点在于它基本不碰 GDI 那一套底层绘制而是把 WPF 自带的内置文档引擎 FlowDocument 当作核心配合 RichTextBox 当宿主容器在它之上做功能扩展。看完你会发现加粗、改字号、调颜色、插表格、查找替换这些「看起来复杂」的能力大半是 WPF 框架替你干完的。这份资源适合两类人一类是刚接触 WPF 想找一个完整 C/S 端文本编辑案例的开发者另一类是准备做内部工具、进销存、OA 系统的桌面端想抄一套编辑器壳子的熟手。它的体量不大但目录结构、命令绑定、可视树刷新这些点都很典型值得拆开看一遍。2. 架构设计从 MainWindow 到 FlowDocument 的职责划分2.1 面板布局工具栏、编辑区与状态栏三段式整个工程最常见的布局是三段式顶部工具栏负责排版命令中间是承载编辑区域的 ScrollViewer RichTextBox底部放一个状态栏显示当前行列号或字数。这个 demo 的主窗口一般用 DockPanel 做根布局工具栏用 ToolBarTray 横向排开编辑区默认撑满剩余空间。DockPanel ToolBarTray DockPanel.DockTop ToolBar Button CommandBold ContentB/ Button CommandItalic ContentI/ Button CommandUnderline ContentU/ /ToolBar /ToolBarTray StatusBar DockPanel.DockBottom TextBlock x:NameStatusText/ /StatusBar RichTextBox x:NameMainEditor AcceptsTabTrue VerticalScrollBarVisibilityAuto HorizontalScrollBarVisibilityAuto/ /DockPanel布局拆解完后有一个关键点DockPanel 的挂靠顺序决定控件谁先占位。ToolBarTray 挂 TopStatusBar 挂 Bottom中间的 RichTextBox 不需要挂 Dock它会自动吞掉剩余区域。AcceptsTab 设置为 True 是必要的否则编辑器里按 Tab 会直接跳焦点而不是插入制表符做文档编辑的工具如果不开这个属性用户第一轮操作就会觉得「手感不对」。这个 demo 最值得学的是「工具栏只管发命令不管文档状态」。Bold、Italic、Underline 这些直接用 WPF 内置的 ApplicationCommands不需要写任何事件处理代码由 RichTextBox 自己响应。后面的章节会拆开讲这套命令链路这里先记住一个原则凡是框架内置命令能覆盖的不要自己注册事件否则后面做撤销栈、焦点管理时会非常痛苦。2.2 文档模型FlowDocument 为什么是核心这个项目的核心不在一行一行的 UI 代码而在它把「文档内容」抽象成 FlowDocument 对象。段落是 Paragraph行内元素是 Run图片是 BlockUIContainer 或 Figure表格是 Table。这种树形结构给开发带来的最大好处是保存、加载、序列化、遍历查找全是现成的 API。private FlowDocument LoadDocument(string path) { var doc new FlowDocument(); using (var stream File.OpenRead(path)) { var range new TextRange(doc.ContentStart, doc.ContentEnd); range.Load(stream, DataFormats.Rtf); } return doc; }上面这段做的事情是把 RTF 文件读入一个全新的 FlowDocument然后赋值给 RichTextBox.Document。Load 的第二个参数除了 DataFormats.Rtf还支持 Xaml、Text 等格式这是后面做「另存为纯文本」「导入 Word 排版文件」的基础。需要注意的是TextRange.Load 在加载大文件时是同步阻塞的几 MB 的 RTF 会让 UI 卡顿我一般在正式项目里会把它丢到 Task.Run 里加载完再切回 UI 线程赋值 Document。与 FlowDocument 强绑定的另一个设计是「编辑历史」。用户每次输入、删除、改格式RichTextBox 都会把操作推进它的撤销栈。前提是你没有绕过 TextRange 直接去改 Document 的子树——一旦你手写paragraph.Inlines.Add(new Run(...))却不通过 TextRange撤销就失效了这是后续避坑章节里要重点盯的问题。2.3 命令绑定与 MVVM 取舍这个 demo 没有把 MVVM 贯彻到底它在工具栏和代码后台之间用的是「命令 代码隐藏」的混合方式内置命令直接 Binding自定义功能则写进窗口的 Click 或 CommandBinding。这个取舍在实际开发里很常见富文本编辑器的格式化操作极度依赖当前焦点和 Selection强行套 ViewModel 反而会把简单事情绕复杂。private void OnBoldExecuted(object sender, ExecutedRoutedEventArgs e) { var editor e.Parameter as RichTextBox; if (editor null) return; editor.Selection.ApplyPropertyValue(TextElement.FontWeightProperty, FontWeights.Bold); }CommandBinding 的好处是命令会沿可视树向上冒泡工具栏按钮只管CommandBold不需要知道焦点在哪个控件里。这在拆解上有两层意思第一层命令系统天然支持焦点感知用户点了表格里的单元格再点加粗命令照样能找到正确的 Selection第二层如果哪天你把工具栏换成了快捷键只需要给窗口加 KeyBinding指向同一个命令业务逻辑一行不用改。2.4 目录结构一眼能找到扩展点这类 demo 的工程目录一般按功能分层而不是按控件分层。做二次开发时最常看的三个文件夹分别是 Models、Views、Helpers。/src /Models DocumentModel.cs // 封装文档保存、加载、导出 /Views MainWindow.xaml // 编辑区 工具栏布局 FontDialog.xaml // 自定义字体设置弹窗 /Controls /ToolBar /StatusBar /Helpers DocumentSerializer.cs // XAML/RTF/Text 互转 SelectionHelper.cs // 选区遍历与格式读取扩展点就藏在 Helpers 和 Models 里。DocumentModel 管文档的生命周期SelectionHelper 管格式读取与回写。如果你要加「居中对齐」「首行缩进」这类新能力标准做法是在 SelectionHelper 里加一个静态方法然后在工具栏加一个按钮绑定一个新的 RoutedCommand。这比把逻辑全塞进 MainWindow.xaml.cs 要干净至少改到第 20 个功能时你不会想重写。3. 核心功能拆解从加粗到查找替换的完整链路3.1 字符格式加粗、斜体、下划线是怎么落到文档里的格式化操作最核心的 API 是Selection.ApplyPropertyValue。它接受两个参数第一个是依赖属性比如 TextElement.FontWeightProperty第二个是值本身。难点在于同一个属性不同的值范围有不同的应用语义。private void ToggleBold(object sender, RoutedEventArgs e) { var editor MainEditor; var selection editor.Selection; if (selection.IsEmpty) { // 没有选区时切换输入模式的加粗状态 editor.Focus(); return; } var currentWeight selection.GetPropertyValue(TextElement.FontWeightProperty); var isBold currentWeight ! DependencyProperty.UnsetValue currentWeight.Equals(FontWeights.Bold); selection.ApplyPropertyValue(TextElement.FontWeightProperty, isBold ? FontWeights.Normal : FontWeights.Bold); }这段逻辑里有几个容易写错的细节GetPropertyValue 返回的是 object它可能拿到 UnsetValue表示「这段文字没被显式设置过字号」此时要用 Normal 作为默认值另一个坑是判断加粗状态不能只比对 FontWeights.Bold因为用户可能选中了半段加粗半段不加粗的文字GetPropertyValue 返回的可能是混合状态的值。严谨一点的写法是遍历 TextRange 的段落去逐个判断但 demo 里用无选区时切换输入模式来处理也算一种务实兜底。3.2 字号与颜色为什么改完没反应字号和颜色走的是同一个 ApplyPropertyValue但有两个独立属性要分清TextElement.FontSizeProperty 和 TextElement.ForegroundProperty。初学者最容易犯的错是写成 Control.FontSize 或者给 RichTextBox 本身设置 FontSize——那样只影响新插入的文本已有的段落纹丝不动。private void ApplyFontSize(double size) { var selection MainEditor.Selection; if (selection.IsEmpty) return; selection.ApplyPropertyValue(TextElement.FontSizeProperty, size); MainEditor.Focus(); }FontSize 的取值建议限制在一组预设值里比如 10、12、14、16、18、24、36。因为 WPF 的字体度量是基于这个值参与计算的用户随便输入 13.7 这种数字虽然不报错但打印排版时极易出现字号对不齐的问题。我一般会在工具栏放 ComboBox 让它只从预设项里取值同时显示当前选区的实际字号。颜色处理的思路类似但要注意 TextElement.ForegroundProperty 接受的是 Brush 对象不是字符串Red。如果从 XAML 配置读颜色先转成 Brush 再应用。3.3 插入图片和表格非原生内容的两条路FlowDocument 对图片和表格的原生支持其实是两套不同的机制。图片用 BlockUIContainer表格用 Table。做编辑器的时候插入图片通常靠剪贴板用户复制一张图片然后粘贴RichTextBox 内部自动创建 BlockUIContainer 把它包进去。代码层面的插入方式则更直白private void InsertImage(string imagePath) { var bitmap new BitmapImage(); bitmap.BeginInit(); bitmap.UriSource new Uri(imagePath, UriKind.Absolute); bitmap.CacheOption BitmapCacheOption.OnLoad; bitmap.EndInit(); var image new Image { Source bitmap, MaxWidth 480 }; var container new BlockUIContainer(image); var paragraph new Paragraph(container); MainEditor.Document.Blocks.InsertAfter( MainEditor.CaretPosition.Paragraph, paragraph); MainEditor.Focus(); }注意MaxWidth 480这个细节不限制宽度的话一张几万像素的大图会把布局直接撑爆之后的滚动、缩放全是玄学级卡顿。BitmapCacheOption.OnLoad 也是必写的它保证图片在加载流程里立即把数据读到内存不会持有一个指向文件的独占句柄——否则用户选了图之后原文件被占用删不掉这在很多编辑器工具里是个隐蔽 bug。表格的插入用的是 Table 对象构造 TableRowGroup 再往里塞 TableRow 和 TableCell。富文本编辑器里的表格难点不在创建而在选中范围用户点进表格某个单元格时Selection 可能落在 cell 内部而在 document 层面看起来没有选中任何 block这时按 Delete 会出现「删不掉」的错觉。3.4 查找与替换一定要基于 TextRange 而不是正则直接改文本查找替换是每个编辑器躲不掉的功能。这个 demo 的实现思路是先把 Document 的内容通过遍历 TextRange 的 Text 属性拼成一个完整字符串再在字符串上做查找命中后用 TextRange 定位到实际位置并设置高亮或替换。private ListTextRange FindMatches(string keyword) { var results new ListTextRange(); var navigator MainEditor.Document.ContentStart; while (navigator ! null navigator.CompareTo(MainEditor.Document.ContentEnd) 0) { string textInRun navigator.GetTextInRun(TextPointerContext.Text); int index textInRun.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); if (index 0) { var start navigator.GetPositionAtOffset(index); var end navigator.GetPositionAtOffset(index keyword.Length); results.Add(new TextRange(start, end)); } navigator navigator.GetNextContextPosition(TextPointerContext.Text); } return results; }这里有一个很多人踩过的坑直接用MainEditor.Document.ContentStart.GetTextInRun只能拿到单个 Run 里的文本而一个段落可能有多个 Run 拼接。上面这段代码用 while 循环逐个 Run 扫描避免遗漏跨 Run 的关键词。替换的时候对每个命中的 TextRange 调用ApplyPropertyValue或者直接range.Text newValue但注意替换会改变文档结构所以找完全部匹配再统一替换不要边找边替。4. 编译运行与调试从拿到源码到窗口弹出来4.1 环境准备与依赖还原这个项目是标准 .NET 桌面工程目标框架一般是 .NET Framework 4.7.2 或 .NET 6/8具体看工程文件里的 TargetFramework 字段。环境上最省心的组合是 Visual Studio 2022 安装「.NET 桌面开发」工作负载。如果打开 sln 提示「不受支持的 SDK 版本」多半是本地 SDK 与 TargetFramework 不匹配——直接改用 Visual Studio Installer 补装对应版本的 .NET SDK 即可。Visual Studio 2022 - 安装「.NET 桌面开发」工作负载 - 打开 sln 后等待 NuGet 还原 - 确保 packages 文件夹或 global cache 里有资源老项目最容易在这步翻车目标框架是 .NET Framework 4.6.2 但机器上只有 .NET 8 SDKNuGet 还原不报错一编译却报找不到程序集 Reference。我的习惯是打开 csproj 看 TargetFramework如果老版本依赖太重直接整体升级到 .NET 8改几个 API 命名空间即可投入产出比很高。4.2 关键配置Application 资源和启动逻辑App.xaml 里一般只有一个 StartupUri指向 MainWindow。如果项目里引入了主题资源、字体资源或者工具栏图标库都会挂在 App.xaml 的 ResourceDictionary 里。启动顺序是先加载资源再创建窗口所以如果资源字典里引用了一个不存在的合并字典窗口永远不会弹出来而报错信息藏在输出窗口的 XamlParseException 里。Application.Resources ResourceDictionary ResourceDictionary.MergedDictionaries ResourceDictionary Source/Resources/Styles.xaml/ /ResourceDictionary.MergedDictionaries /ResourceDictionary /Application.Resources从排查角度讲窗口没弹出来但编译成功九成是资源字典或控件模板抛了异常。第一步先按 F5 看 Output 窗口的第一行异常描述第二步把 App.xaml 里的资源字典暂时清空用排除法定位出问题的资源文件。4.3 验证功能一小时内跑通全流程跑起来后的验证清单要覆盖六件事输入文字、加粗、改字号、插图片、查找、保存。每一步都对应到一个具体控件或命令哪一步没反应就说明对应链路断了。这里列一个我实际复现时常用的动作路径在编辑区输入一段中文确认 IME 输入正常、光标不跳动选中文字点 B 按钮看字重变化再按 CtrlZ 确认撤销正常从资源管理器拖一张图进编辑区确认 BlockUIContainer 接受拖放按 CtrlF 弹查找框输入关键词确认跨 Run 命中点保存按钮生成 RTF 文件双击用 Word 打开比对内容4.4 运行期常驻小心文件保存格式保存实现通常分两条路RTF 用 TextRange.SaveXAML 用 XamlWriter.Save。这个 demo 一般默认存 RTF因为兼容 Word 场景。关键点在于 Save 后要判断返回值否则文件可能写了一部分就静默失败using (var stream File.Create(savePath)) { var range new TextRange(MainEditor.Document.ContentStart, MainEditor.Document.ContentEnd); range.Save(stream, DataFormats.Rtf); stream.Flush(); }我这里把 Flush 和关闭分开了是为了排除缓冲没落盘的偶发问题。如果保存后打开文件发现内容残缺先检查是否在 Save 之前动了 Document 的 Blocks 集合比如 InsertAfter 传入的 block 不属于当前文档这会写入一个引用异常内容。5. 避坑排查字号失效、图片空白、撤销失灵的三条血泪经验5.1 字号改完没反应先检查有没有焦点现象选中一段文字从字号下拉框选 18文字没变大。原因ComboBox 的 DropDown 抢走了键盘焦点RichTextBox.Selection 在失焦后会自动收缩成插入符不再指向任何字符。解决在 ComboBox 的 SelectionChanged 事件里先让 RichTextBox 重新 Focus再 ApplyFontSize。private void FontSize_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (FontSizeCombo.SelectedItem is double size) { MainEditor.Focus(); // 先抢回焦点 ApplyFontSize(size); // 再改字号 } }这个问题的隐蔽之处在于 Initial 打开编辑器的第一次操作不会有 bug因为焦点本来就在编辑器里。一旦你点了字号框再回来点加粗就可能出现「按钮点击无效需要再点一次」的错觉。我在做类似项目时会把所有格式操作统一包一层FocusThenApply的封装杜绝这类状态错乱。5.2 插入的图片保存后再打开是空白现象拖进编辑器时图片显示正常保存成 RTF 再打开图片位置空了一块。原因TextRange.Save 序列化 RTF 时BlockUIContainer 里的 BitmapImage 如果设置了 UriSource 且加载方式是延迟的保存时可能拿不到像素数据。解决加载图片时强制BitmapCacheOption.OnLoad确保图片数据已经在内存里。如果还不行把图片复制进项目资源目录代码里用相对包 URI 引用而不是传一个绝对路径。这类问题属于「保存时拿不到位图数据」的一类。最省心的兜底方案是保存前遍历 Document 里所有 BlockUIContainer把内部的 Image 转成 PngBitmapEncoder 字节数组再替换 Source 为内存流里的 BitmapImage。这样虽然内存占用高一点但序列化永远不丢图。5.3 撤销失灵绕过 TextRange 的修改都不在历史里现象加粗、改色都能撤销但通过代码插入的图片一删按 CtrlZ 回不来。原因直接Document.Blocks.Add或Paragraph.Inlines.Add这些 API 不会写撤销记录只有 TextRange 的操作和输入框的输入才会入栈。解决需要可撤销的插入操作用 TextRange 的 Insert 方法而不是 Blocks.Add。var insertRange new TextRange(MainEditor.CaretPosition, MainEditor.CaretPosition); insertRange.Insert(container); // 走 TextRange 注册撤销这个坑是后期功能做多了才容易爆发的。一旦代码里有几十处直接操作 Blocks 集合的位置撤销行为就会变得时灵时不灵。我自己踩过一次之后定了条死规矩任何非键盘操作要进撤销栈一律走 TextRange 或者命令系统底层 Blocks 集合只能用于初始化或清空文档。5.4 拖拽文件到编辑器会闪退现象从文件管理器拖一个 Word 文档到窗口里光标变成禁止符松开后程序崩溃。原因RichTextBox 默认没有处理文件拖入拖到它上面的可能是 FileDrop 数据WPF 尝试把整个文件内容当作 RTF 解析失败。解决给 RichTextBox 的 PreviewDragOver 和 PreviewDrop 挂事件判断 e.Data.GetDataPresent(DataFormats.FileDrop)拦截后自己读文件内容。private void Editor_PreviewDragOver(object sender, DragEventArgs e) { e.Effects e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; e.Handled true; }有了这个拦截拖入非文本文件时至少不会闪退。再进一步可以在这里判断扩展名是 .rtf、.txt 还是 .docx分别走不同的解析逻辑这就是把玩具 demo 往生产工具推的第一步。5.5 长文档渲染卡顿现象粘贴几万字后每敲一个字都有明显延迟。原因RichTextBox 把整个文档都放在可视树里实时布局FlowDocument 的内容越多布局成本越高。解决开启 RichTextBox.IsDocumentScrollEnabled 并把文档显示模式设为页面模式或者接受它本身的局限在工具类场景里限制单次粘贴的文档规模。更治本的做法是换用按需虚拟化的文档呈现控件但那等于把整套编辑能力换掉改造成本极大非必要不建议。6. 二次开发从编辑器到「导出 PDF 的写作工具」这个 demo 最好的改造起点不是增加更多字体按钮而是打通「导出 PDF」。理由很现实桌面端富文本编辑器做内容管理工具最终用户都要把成品文件交出去而 Word 不是人人都装PDF 才是分发终态。改造思路是三段式拿到当前 Document 的内容 - 创建打印页面 - 用 WPF 的文档打印服务输出。private void ExportToPdf(string fileName) { // 创建固定文档并设置页面尺寸 var docPaginator ((IDocumentPaginatorSource)MainEditor.Document).DocumentPaginator; var printDialog new PrintDialog { PrintQueue new LocalPrintServer().DefaultPrintQueue, PrintTicket new PrintTicket { PageSize new Size(842, 595) } // A4 横向 }; XpsDocumentWriter writer PrintQueue.CreateXpsDocumentWriter( new XpsDocument(fileName, FileMode.Create)); writer.Write(docPaginator); }这段代码的核心是 DocumentPaginator——FlowDocument 自带分页能力把分页结果写进 XPS 流就是 PDF。PrintTicket 里的 PageSize 决定了输出页面的物理尺寸单位是 1/96 英寸A4 横向就是 842 x 595纵向则是 595 x 842。这里最容易出错的是 Size 的单位WPF 里全部按 DIP 算如果你填成毫米或像素导出的 PDF 页面大小会离谱。改造验证的方法很直接导出前后对比源文件与 PDF 的排版。我会在同一个文档里塞一段中文、一段英文、一张图片和一个简单表格跑一次导出然后翻 PDF 检查三类内容是否都出现、表格单元格没有溢出、图片没有拉伸变形。只要这三类内容正常这个导出链路基本就是可用的。说到这张打印 Ticket还有一层要注意的PrintTicket 的 PageSize 和打印机默认的纸张大小如果不一致最终输出容易边缘被裁剪。一个稳妥的做法是读默认 PrintQueue 的 DefaultPrintTicket只改 PageMediaSize其余字段不动。从那以后我每次做这类导出功能都强制走一遍「先取默认票据再改尺寸」的流程翻车率从三次降到零次。希望这份拆解能帮你把这个 demo 真正跑起来并在改造时少走几步弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站