简介:资源为基于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.Dock="Top"> <ToolBar> <Button Command="Bold" Content="B"/> <Button Command="Italic" Content="I"/> <Button Command="Underline" Content="U"/> </ToolBar> </ToolBarTray> <StatusBar DockPanel.Dock="Bottom"> <TextBlock x:Name="StatusText"/> </StatusBar> <RichTextBox x:Name="MainEditor" AcceptsTab="True" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"/> </DockPanel>布局拆解完后有一个关键点:DockPanel 的挂靠顺序决定控件谁先占位。ToolBarTray 挂 Top,StatusBar 挂 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 的好处是命令会沿可视树向上冒泡,工具栏按钮只管Command="Bold",不需要知道焦点在哪个控件里。这在拆解上有两层意思:第一层,命令系统天然支持焦点感知,用户点了表格里的单元格再点加粗,命令照样能找到正确的 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 List<TextRange> FindMatches(string keyword) { var results = new List<TextRange>(); 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 SDK,NuGet 还原不报错,一编译却报找不到程序集 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 按钮,看字重变化,再按 Ctrl+Z 确认撤销正常
- 从资源管理器拖一张图进编辑区,确认 BlockUIContainer 接受拖放
- 按 Ctrl+F 弹查找框,输入关键词,确认跨 Run 命中
- 点保存按钮,生成 RTF 文件,双击用 Word 打开,比对内容
4.4 运行期常驻小心:文件保存格式
保存实现通常分两条路:RTF 用 TextRange.Save,XAML 用 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 的修改都不在历史里
现象:加粗、改色都能撤销,但通过代码插入的图片一删,按 Ctrl+Z 回不来。原因:直接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 真正跑起来,并在改造时少走几步弯路。
本文还有配套的精品资源,点击获取