ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

WPF富文本编辑器实战:基于FlowDocument仿Word核心实现解析

WPF富文本编辑器实战:基于FlowDocument仿Word核心实现解析 简介面向WPF开发者的富文本编辑器开源Demo模仿Word常用编辑体验适合需要快速搭建自定义文本编辑应用的开发者。压缩包仅753KB包含289个文件76个cs源码文件构成主要业务逻辑10个xaml配合15个baml定义界面与编译资源54个png、57个gif提供工具栏图标与演示动图另有9个dll支撑扩展功能。编辑器支持基本文本操作、查看HTML源码、打印、导出纯文本、插入图片与表格覆盖常见办公编辑场景。目前已有2870人学习/下载。通过阅读源码可掌握WPF中RichTextBox/FlowDocument的使用、菜单与对话框设计、图片表格插入等关键技术还可基于现有结构扩展新格式或对接第三方库是学习WPF桌面开发与富文本处理的一份实用参考。 WPF里做富文本编辑器说难不难说简单也真不简单。系统自带的RichTextBox开箱即用默认支持加粗、斜体、下划线、复制粘贴、撤销重做演示效果足够唬人。可一旦你想让它“看起来像Word”事情就变得有意思了Ribbon菜单、字体面板、查找替换、表格图片、文档序列化一个比一个磨人。这篇文章我会结合个人开源的仿Word demo把WPF富文本编辑器的整体架构、核心功能实现思路和实操过程中踩过的坑全部梳理一遍希望对正在做WPF入门项目或正被RichTextBox折磨的朋友有帮助。这个demo的代码量不大核心就在“仿”字上Ribbon风格的界面、字体格式工具栏、查找替换弹窗、插入表格和图片、保存为RTF或XAML。文章覆盖的不只是功能实现还包括为什么选择这套技术栈、哪些地方容易翻车。适合三类人正在做WPF毕业设计或课程设计的朋友、想在WPF项目里内置一个富文本编辑器的开发者、以及所有对FlowDocument文档模型感兴趣的人。1. 项目整体设计与技术选型思路1.1 为什么选WPF而不是WinForms或Web做富文本编辑器可选路线其实不少WinForms里有现成的RichTextBox网页版有各种开源的富文本框架为什么还要用WPF从零搞一套核心原因是WPF的文档模型FlowDocument。FlowDocument和HTML文档非常像段落、列表、表格、图片、超链接都是一等公民天然支持流式布局和自动换行。操作它的时候你面对的不再是零散的文本字符串而是一个完整的文档对象树可以精确控制任何一个字符、段落、表格单元的样式。WinForms的RichTextBox虽然底层封装的也是老牌编辑控件但它的API风格偏老旧自定义扩展能力远不如FlowDocument灵活。至于Web方案做跨平台很香但如果你要做的是纯Windows桌面端、还要求跟系统Office体验一致的高密度字体渲染那WPF的ClearType渲染和硬件加速优势就体现出来了。另外还有一个现实因素在.NET桌面开发生态里WPF仍然是“富客户端界面”的主流选择。它跟MVVM模式配合得最好界面层和业务逻辑层分离得更干净团队协作时代码不容易乱。加上Fluent Ribbon这类开源UI库已经成熟做Ribbon工具栏并不需要从零磨轮子。综合下来WPF做桌面端富文本编辑器在技术选型上是很稳妥的答案。1.2 仿Word到底要仿哪些点开始写代码之前我列了一张功能清单把“仿Word”拆成了几个核心模块。如果你第一次做建议也先做这张表不然很容易写着写着就跑偏功能模块具体能力实现建议文件操作新建、打开、保存支持XAML和RTF格式用TextRange读取文档首尾内容序列化成本很低剪贴板复制、剪切、粘贴、选择性粘贴纯文本依赖RichTextBox自带命令字体格式字体、字号、加粗、斜体、下划线、删除线、文字颜色、高亮Selection.ApplyPropertyValue段落格式左中右对齐、两端对齐、行距、项目符号、数字编号操作Paragraph属性插入功能表格、图片、超链接Block、InlineUIContainer、Hyperlink查找替换大小写匹配、区分全半角、全部替换TextPointer TextRange视图状态栏实时显示字数、光标行列TextPointer计算行号这张表不用完全照搬你完全可以根据自己的场景增删。比如要做的是一个简单的邮件编辑器那表格和图片都可以砍掉。但如果你要的是“仿Word”的demo上面的模块至少要做掉八成不然打开界面一眼就露怯了。1.3 技术架构Ribbon FlowDocument RoutedCommand架构上我采取了“三层分离”的方式最上层是Ribbon工具栏和状态栏负责交互展示中间层是RoutedCommand命令集合把“用户点击加粗按钮”和“实际执行加粗逻辑”解耦最底层是RichTextBox和FlowDocument负责承载文档内容和编辑行为。这里有个很多新手容易踩的坑富文本编辑器的很多操作比如Selection、Document不是依赖属性MVVM模式下没法直接绑定到ViewModel。你不可能写一个SelectedText属性然后双向绑定。我当时的做法是把命令放到一个静态命令类里通过CommandBinding把命令和视图层的事件处理函数关联起来ViewModel只负责维护文档的元数据比如保存路径、最近打开文件列表真正的文本操作逻辑留在Code-behind里。这看起来违背了一点MVVM洁癖但在富文本编辑器这个场景里是最务实、最不容易出错的做法。2. 核心功能模块的实现原理2.1 文本格式化Selection与TextElement的联动富文本编辑器最基础的能力就是加粗、斜体、下划线、改字号、改字体。Word里你做完一次格式设置光标往后再输入的文字依然沿用这个格式这个特性WPF是天然支持的因为RichTextBox的Selection会自动带上当前的TextElement属性设置。核心代码非常简洁以加粗为例private void ApplyBold() { var selection richTextBox.Selection; var fontWeight selection.GetPropertyValue(TextElement.FontWeightProperty); // 如果当前不是加粗就设置为加粗否则取消加粗 selection.ApplyPropertyValue( TextElement.FontWeightProperty, fontWeight.Equals(FontWeights.Normal) ? FontWeights.Bold : FontWeights.Normal); }斜体、下划线、删除线、文字颜色、高亮色的写法完全一致只是替换依赖属性而已。注意判断当前状态时最好把null也当作“未设置”处理否则你第一次点击可能会因为拿到null而永远走不进判断逻辑。我用的是fontWeight DependencyProperty.UnsetValue来判断效果等效你也可以直接判null实测更省心。字号和字体的下拉框稍微特殊一点。字号的原理是FontSizeProperty按像素设值但ComboBox里展示的是“小五”“五号”“四号”这类中文字号和对应的磅数值。处理方式很简单准备一张数值映射表把中文字号对应到磅值再在界面显示的时候做一次换算。字体下拉框的坑在于你选完字体后GetPropertyValue(FontFamilyProperty)返回的可能是FontFamily对象而不是string直接ToString()又会带上一堆平台路径信息。稳妥的写法是先判断类型再做转换。2.2 查找替换在FlowDocument中精确游走这是整个demo里坑最多的地方。RichTextBox的Document不像TextBox那样有个简单的Text属性你要找一段文字必须通过TextPointer在文档里移动定位。核心思路是这样从ContentStart开始用GetTextInRun(LogicalDirection.Forward)取出当前Run里的文本用IndexOf找关键词位置找到后用GetPositionAtOffset定位起点和终点封装成TextRange。public ListTextRange FindAll(string keyword, bool matchCase) { var results new ListTextRange(); var documentRange new TextRange(richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd); var navigationStart richTextBox.Document.ContentStart; var searchEnd richTextBox.Document.ContentEnd; var comparison matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; while (navigationStart.CompareTo(searchEnd) 0) { var textInRun navigationStart.GetTextInRun(LogicalDirection.Forward); if (!string.IsNullOrEmpty(textInRun)) { var index textInRun.IndexOf(keyword, comparison); while (index 0) { var start navigationStart.GetPositionAtOffset(index); var end navigationStart.GetPositionAtOffset(index keyword.Length); results.Add(new TextRange(start, end)); index textInRun.IndexOf(keyword, index keyword.Length, comparison); } } navigationStart navigationStart.GetNextContextPosition(LogicalDirection.Forward); } return results; }这段代码有个明显的局限如果关键词被拆在两个相邻的Run里比如“AB”被拆成Run1里的“A”和Run2里的“B”上面的IndexOf就找不到。Word本身对这种跨Run搜索做了很复杂的处理demo阶段我们可以简化只搜索单个Run内的文本。但心里务必清楚这个边界不然用户一旦遇到带超链接、带拼音标注的文档查找结果就会“漏词”。替换就更要注意顺序把找到的Range列表倒序遍历再逐个赋值否则前面替换后后面的TextPointer就失效了。2.3 表格、图片、超链接的插入插入表格是WPF里相对反直觉的一块因为Table的构建远没有HTML那么丝滑。你需要先建Table再往里加TableRowGroup、TableRow、TableCell而TableCell里又得套一个Paragraph才能放文本private void InsertTable(int rows, int columns) { var table new Table { CellSpacing 0, BorderBrush Brushes.Gray, BorderThickness new Thickness(0.5) }; var group new TableRowGroup(); table.RowGroups.Add(group); for (var r 0; r rows; r) { var row new TableRow(); for (var c 0; c columns; c) { var cell new TableCell(new Paragraph(new Run( ))) { BorderBrush Brushes.Gray, BorderThickness new Thickness(0.5), Padding new Thickness(4) }; row.Cells.Add(cell); } group.Rows.Add(row); } var selection richTextBox.Selection; if (selection.IsEmpty) { // 在光标所在段落后面插入 richTextBox.Document.Blocks.InsertAfter(richTextBox.CaretPosition.Paragraph, table); } else { // 有选中区域就替换选中内容 selection.Insert(table); } }如果插入后表格没有边框别忘了给每个单元格设置BorderBrush和BorderThickness这是新手的重灾区。插入图片相对简单用InlineUIContainer包住Image再插入当前光标位置。但有几点要注意大图片直接塞进FlowDocument会严重拖慢界面插入前最好做一次尺寸压缩限制最大宽度另外如果图片路径很长不要在UI线程上用new Uri(filePath)直接加载涉及磁盘IO耗时建议先走一步异步解码。超链接的做法是构造Hyperlink对象设置NavigateUri然后处理它的RequestNavigate事件用Process.Start打开浏览器。不处理事件的话点击超链接不会有任何反应。2.4 文档序列化RTF与XAML互转仿Word的编辑器肯定要支持保存和打开文档这里我把“格式”定为两种WPF原生的XAML格式和通用性更强的RTF格式。XAML的好处是无损图片、样式、表格全部保留RTF的好处是可以被Word、写字板打开。两者在WPF里都是通过TextRange.Save完成代码几乎一样public string ExportToRtf() { var textRange new TextRange(richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd); using (var stream new MemoryStream()) { textRange.Save(stream, DataFormats.Rtf); stream.Seek(0, SeekOrigin.Begin); using (var reader new StreamReader(stream, Encoding.UTF8)) { return reader.ReadToEnd(); } } }加载时的逻辑就是TextRange.Load(stream, DataFormats.Rtf)或Load(stream, DataFormats.Xaml)。这里有个不易察觉的坑如果用Save(stream, DataFormats.XamlPackage)保存图片会以独立资源包的形式嵌进XAML文件如果没有一并保存对应的资源包下次加载时图片就丢了。demo里为了省事我统一用DataFormats.Xaml处理图片先转成Base64字符串内联保存。这样单文件就能承载所有内容兼容性也好很多。3. 实操记录核心代码与踩坑现场3.1 CommandBinding绑定让工具栏按钮优雅驱动文本工具栏按钮如果直接绑定Click事件代码会很快失控。我用的命令体系是WPF自带的RoutedCommand先定义静态命令类public static class EditorCommands { public static readonly RoutedCommand ToggleBold new RoutedCommand(ToggleBold, typeof(EditorCommands)); public static readonly RoutedCommand ToggleItalic new RoutedCommand(ToggleItalic, typeof(EditorCommands)); public static readonly RoutedCommand ToggleUnderline new RoutedCommand(ToggleUnderline, typeof(EditorCommands)); public static readonly RoutedCommand Find new RoutedCommand(Find, typeof(EditorCommands)); public static readonly RoutedCommand Replace new RoutedCommand(Replace, typeof(EditorCommands)); }然后在窗体构造函数里注册CommandBindingCommandBindings.Add(new CommandBinding(EditorCommands.ToggleBold, HandleToggleBold)); CommandBindings.Add(new CommandBinding(EditorCommands.Find, HandleFind));按钮这边直接写Command{x:Static local:EditorCommands.ToggleBold}不需要任何事件。好处是后续如果想加入快捷键比如CtrlB调用加粗只需要相同命令挂在KeyBinding上逻辑完全复用。这里还要注意一点加粗按钮最好做成ToggleButton类型并且实时反映当前文本是否处于加粗状态。实现方式可以在SelectionChanged事件里获取GetPropertyValue再刷新按钮的IsChecked状态。3.2 状态栏字数统计与光标行列显示状态栏能实时显示字数、行号、列号会极大拉高demo的“专业感”。字数统计简单遍历Document的文本长度即可。但要注意中文环境里大家说的“字数”通常是字符数而Word显示的“字数”还包含“字符数不计空格”等口径。我demo里做了简化统计所有可见字符过滤掉空白符和段落标记。光标行列号的实现稍微绕一点。WPF的RichTextBox没有直接提供“第几行第几列”的接口我的做法是拿当前CaretPosition通过GetLineStartPosition计算出从文档开头的行偏移再用TextPointer.GetOffsetToPosition算出字符偏移量。代码量不大但处理的时候一定要考虑换行符和段落结束符的差异不然每按一次回车行号就偏一位。我在实际调试时最快遇到的问题就是文本没有换行但状态栏的行号在增加排查半天发现是Paragraph的换行符被重复统计了。3.3 粘贴去格式与图片粘贴处理富文本编辑器里用户从网页或Word复制内容粘贴进来格式经常会乱。Word的解决方案是弹出“粘贴选项”demo不需要做这么细但我建议至少提供“粘贴为纯文本”的命令。实现思路是监听RichTextBox的PreviewExecuted事件拦截ApplicationCommands.Paste把剪贴板里的Text内容按纯文本插入private void OnPreviewPaste(object sender, ExecutedRoutedEventArgs e) { if (!pasteAsPlainText) { return; } e.Handled true; var text Clipboard.GetText(TextDataFormat.UnicodeText); richTextBox.CaretPosition.InsertTextInRun(text); }另外图片粘贴也有坑从浏览器复制图片后剪贴板里可能同时有多份数据Bitmap、PNG、HTML等如果你直接用Clipboard.GetImage()得到的位图可能不是用户真正想粘贴的高清图。更稳妥的方法是先检查剪贴板文件拖放数据没有文件数据再尝试GetImage。这个处理顺序直接影响用户体感。3.4 让“仿Word”的交互更到位界面上的细节最能拉开demo与其他作业之间的距离。刚才提到Ribbon工具栏我推荐优先使用开源库Fluent Ribbon它的视觉样式已经非常接近Office官方。但库只是第一步真正让用户体验“像Word”的是这些细节字号下拉框要能直接输入数值并按回车生效不能只支持选择颜色选择器要保留“最近使用颜色”区域状态栏右侧应该有缩放滑块拖动时改变编辑区缩放比例文本高亮和文字颜色要为透明色保留入口方便用户去除已设置的格式。这些交互单个看都很小但加在一起用户就会觉得“这编辑器做得挺认真的”。凡是展示给用户的工具类demo交互完整度比功能数量重要得多。我曾经只顾着堆功能忘记做“高亮颜色可以取消”结果用户在测试时反复问我怎么把黄色底纹去掉那一瞬间我意识到仿Word仿的不是功能是习惯。4. 常见问题与排查技巧实录4.1 字体、行距等样式设置不生效新手常遇到的第一个问题是加粗按钮明明执行了ApplyPropertyValue但界面上没有任何变化。排查步骤先看是否选中了文本再看Selection.GetPropertyValue返回的到底是null还是FontWeights.Normal。我自己的经验是GetPropertyValue返回DependencyProperty.UnsetValue时说明光标所在的Run没有显式设置过这个属性此时应该用FontWeights.Normal作为默认值比较而不是直接拿返回值去和FontWeights.Bold比较。另外一个隐蔽的问题是如果当前光标正好在段落末尾的“段落标记”里任何格式设置都可能“看起来生效了但按下回车后新段落又变回默认样式”。这是因为段落标记自身的Paragraph属性优先于文本样式。解决方法是设置格式时同时显式处理当前所在的Paragraph或者在应用格式后主动刷新一次Paragraph的样式继承。行距设置我踩过更大的坑。FlowDocument里Paragraph的LineHeight和LineStackingStrategy是配合使用的单独设置LineHeight尤其容易出问题。如果你的行距设置“1.5倍”但界面毫无反应先检查LineStackingStrategy它默认可能是MaxHeight而非BlockLineHeight导致你手动指定的高度被撑开策略覆盖。正确做法是1.5倍行距时先按字号算出行高像素再同时设置LineStackingStrategy LineStackingStrategy.BlockLineHeight和LineHeight 计算值。4.2 撤销重做与UndoManager的边界问题WPF的RichTextBox默认带有撤销支持UndoLimit默认是-1无限还是100实测不同版本行为不同。如果你要限制撤销步数推荐显式设置richTextBox.UndoLimit 100;但要注意撤销的调用方式。直接调用richTextBox.Undo()有时候会抛出InvalidOperationException原因是当前文档栈上存在无法撤销的操作。实践中应该先判断if (richTextBox.CanUndo) { richTextBox.Undo(); }还有一个常见痛点程序里用代码修改FlowDocument时可能会破坏撤销栈。尤其是你调用Document.Blocks.Clear()清空文档后再加载新文档这期间的撤销历史会变得混乱。我的经验是加载文件之前先richTextBox.Document new FlowDocument();换掉整个文档实例而不是去操作旧文档的内容这样撤销栈也会同步重置不会出现“新文档还能撤销到旧文档”的诡异状态。4.3 大文档卡顿与内存飙升富文本编辑器处理长文档性能问题无法回避。我测试过一份带几十张图片的文档直接插入后拖动滚动条卡顿明显。后来做了三件事插入图片前做等比压缩限制最长边为800px加载大文档时用DispatcherPriority.Background异步加载关闭文档时强制释放图片流资源。这三个改动做下来同等文档的滚动流畅度提升非常明显。纯文字类长文档卡顿的另一大原因是多次无谓的UI刷新。比如查找功能把几百个匹配项全部高亮如果你每处理一个匹配项就去重新查一次文档性能一定崩溃。正确做法是先收集所有匹配的TextRange存入列表最后一次性应用高亮属性。另外状态栏字数统计如果放在TextChanged事件里每次都做全文明文字符计数也会造成大文档卡顿要改成500毫秒防抖或者只统计自上次统计以来插入/删除的文本增量。关于这个我贴一个通用的小技巧状态栏更新放在DispatcherTimer里每300毫秒刷新一次即可用户是感知不到延迟的CPU占用却能降不少。4.4 富文本查找容易出的两类边界问题查找功能除了跨Run问题还有两类容易漏掉的情况。第一类是大小写全角匹配用户输入的关键词是半角英文但文档里是全角英文直接IndexOf肯定找不到。demo里我没做全半角归一化但在界面里留了“区分全半角”的复选框默认不勾选这样才更贴近Word用户的预期。第二类是SearchDirection问题Word支持“向上查找”和“向下查找”两个方向。FlowDocument虽然不像TextBox那样有明确的SearchDirection但你可以通过TextPointer的GetNextInsertionPosition或GetPreviousInsertionPosition来决定遍历方向。注意向下查找时起点是光标位置向上查找时起点应该是光标前一个位置否则会把自己光标紧邻的字符漏掉。写在最后的一点个人心得这个demo从搭建骨架到功能完善陆陆续续写了两周。说实话RichTextBox能直接做到的事大概只占整体工作量的30%剩下70%的精力全花在了“仿Word”的交互打磨和边界情形处理上。回头看最有价值的不是某个炫酷的界面效果而是把FlowDocument这个对象模型玩明白了——一旦你理解Block、Inline、TextPointer这套概念以后处理任何文档类功能都会顺很多。如果你也想做一个类似的编辑器我的建议是先严格做减法第一版只做字体格式、查找替换、保存加载能跑通后再逐步加表格和图片。千万不要一上来就想把Word的所有菜单都搬进去你会被永无止境的边界情况拖垮。另外代码里涉及文档序列化和查找遍历的部分建议单独封装成工具类因为这两个模块的测试成本最高逻辑独立后可以方便地写单元测试覆盖。最后分享一个小技巧调试富文本问题时我经常把FlowDocument的XAML实时输出到一个调试面板里。哪些属性被显式设置了、哪些样式是继承的、为什么加粗不生效看XAML一眼就能定位。这套调试思路比断点打在UI层有效率得多希望你也能用上。本文还有配套的精品资源点击获取
返回列表