简介:这是一份基于WPF的开源富文本编辑器代码与Demo,面向希望在.NET桌面应用中实现类Word编辑功能的开发者,尤其适合具备一定C#与XAML基础、想研究富文本处理机制的中高级人员。项目实现了基本文本编辑、查看与编辑HTML源码、打印、导出纯文本、插入图片和表格等能力,可作为自定义编辑器的起点进行扩展与定制。压缩包共289个文件,约753KB,以76个cs源码、10个xaml界面文件、15个baml资源及57个gif、54个png演示素材为主,另含dll、csproj、sln等工程与依赖文件,结构完整可直接编译运行。目前已有2873人学习下载。通过研读源码,读者可掌握WPF图形模型下的复杂编辑操作、文本与图像数据处理及与.NET组件的交互方式,快速搭建类似Word的编辑环境。
1. 仿 Word 的 WPF 富文本编辑器:一份能直接跑起来的开源 Demo 到底长什么样
做过桌面端文档类产品的同行大概都有过这种经历:产品经理一句“我们要在软件里内嵌一个像 Word 那样的编辑区”,后端和前端都沉默了。Web 端还能靠 contenteditable 或者第三方富文本库糊过去,到了 WPF 桌面端,很多人第一反应是去 NuGet 上翻 RichTextBox 的封装,翻完一圈发现要么功能残缺,要么文档少得可怜,要么干脆就是个半成品。这份开源 Demo 的价值就在这里——它把“仿 Word”这件事拆成了一个能编译、能运行、能改的 WPF 工程,而不是一段只存在于博客里的伪代码。
它本质上是一个基于 WPF 的富文本编辑器示例工程,核心围绕 RichTextBox 这个控件做扩展,覆盖了文字排版、字体样式、段落格式、撤销重做、文件读写这些 Word 里最基础也最常用的能力。适合两类人:一类是正在做 WPF 桌面应用、需要内嵌文档编辑功能的开发者,可以直接拿它当起点改;另一类是刚接触 WPF、想通过一个完整案例理解数据绑定、命令、控件模板怎么协同工作的人。下面我按“它怎么搭起来 → 关键功能怎么实现 → 哪里容易翻车 → 怎么往下扩展”的顺序,把这份代码拆开讲。
2. 从 RichTextBox 到仿 Word 编辑区:核心架构与数据绑定怎么落地
WPF 里做富文本,绕不开 RichTextBox。但原生 RichTextBox 只是个“能显示带格式文本的框”,离 Word 那种带工具栏、状态栏、实时格式反馈的编辑区还差得远。这份 Demo 的思路是:把 RichTextBox 当作渲染和输入的核心,外面套一层 MVVM 结构,用命令和绑定把工具栏、格式状态、文档内容串起来。理解这个分层,是能不能改得动的关键。
2.1 为什么选 RichTextBox 而不是自己画一个
有人会想,既然要仿 Word,为什么不干脆用 Canvas 或者自定义控件从头画?答案很简单:文本排版是深水区。光标定位、选区、换行、字体回退、输入法候选框跟随,这些如果自己实现,工作量远超一个 Demo 的范畴。RichTextBox 底层是 FlowDocument 模型,天然支持段落、Run、Span、Table、List 这些结构,等于微软已经帮你把文本排版引擎写好了。你要做的,是在它上面加“Word 感”。
具体到这份代码,它没有去动 RichTextBox 的渲染层,而是把精力放在三件事上:一是工具栏按钮和 RichTextBox 当前选区格式的双向同步;二是把 FlowDocument 的序列化/反序列化封装成打开和保存;三是用命令模式统一处理撤销重做和格式切换。这个取舍很务实——先让功能跑通,再谈性能优化。
2.2 MVVM 结构下 RichTextBox 的绑定难题
WPF 的 RichTextBox 有个出了名的坑:它的 Document 属性不是依赖属性,没法直接Text="{Binding Document}"这样绑。很多新手在这里卡住,最后退化成在代码后台直接操作控件,MVVM 就名存实亡了。这份 Demo 常见的处理方式是附加属性(Attached Property)或者行为(Behavior),把 Document 的变更桥接到 ViewModel。
下面是一个附加属性的简化写法,用来实现 Document 的双向绑定:
public static class RichTextBoxHelper { // 定义附加属性 DocumentBinding public static readonly DependencyProperty DocumentBindingProperty = DependencyProperty.RegisterAttached( "DocumentBinding", typeof(FlowDocument), typeof(RichTextBoxHelper), new FrameworkPropertyMetadata(null, OnDocumentChanged)); public static FlowDocument GetDocumentBinding(DependencyObject obj) => (FlowDocument)obj.GetValue(DocumentBindingProperty); public static void SetDocumentBinding(DependencyObject obj, FlowDocument value) => obj.SetValue(DocumentBindingProperty, value); private static void OnDocumentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is RichTextBox rtb && e.NewValue is FlowDocument doc) { // 把 ViewModel 里的 FlowDocument 挂到控件上 rtb.Document = doc; } } }逻辑说明:附加属性让 XAML 里可以写local:RichTextBoxHelper.DocumentBinding="{Binding CurrentDocument}",从而绕开 Document 不是依赖属性的限制。参数上,FrameworkPropertyMetadata里如果需要支持从控件回写到 ViewModel,还要加BindsTwoWayByDefault,并在 RichTextBox 的TextChanged事件里反向更新。注意这里有个性能陷阱:每次 TextChanged 都重建 FlowDocument 会导致输入卡顿,正确做法是只标记脏状态,保存时再序列化。
2.3 工具栏与选区格式的实时同步
Word 的工具栏有个细节:光标放在加粗文字上,加粗按钮是按下状态;换到普通文字,按钮弹起。这个“格式状态回显”在 WPF 里要靠SelectionChanged事件去读EditingCommands对应的属性。Demo 里一般会封装一个GetCurrentSelectionProperty方法:
private void RichTextBox_SelectionChanged(object sender, RoutedEventArgs e) { var rtb = sender as RichTextBox; if (rtb == null) return; // 读取当前选区的字重,判断是否加粗 object weight = rtb.Selection.GetPropertyValue(TextElement.FontWeightProperty); bool isBold = weight != DependencyProperty.UnsetValue && (FontWeight)weight == FontWeights.Bold; // 通知 ViewModel 更新按钮状态 ViewModel.IsBoldChecked = isBold; }逻辑说明:GetPropertyValue返回的是依赖属性值,如果选区里格式不统一,会返回DependencyProperty.UnsetValue,这时候按钮应该显示为不确定态而不是简单置灰。参数上,FontWeightProperty、FontStyleProperty、TextAlignmentProperty都是常用的探测目标。这里容易翻车的地方是:在 SelectionChanged 里直接改 ViewModel 属性可能触发重入,稳妥做法是加一个_isUpdating标志位做保护。
3. 打开、保存与格式序列化:FlowDocument 的 XAML 与 RTF 两条路
一个编辑器能不能用,很大程度上看它的文件读写靠不靠谱。这份 Demo 在文档持久化上通常会给两条路:一条是 XAML 序列化,一条是 RTF。选哪条,取决于你要的是“自己存自己读”还是“和外部文档互通”。
3.1 XAML 序列化:最简单也最可控
FlowDocument 本身支持XamlWriter.Save和XamlReader.Load,这是最省事的方案。保存时把 Document 写成 XAML 字符串或文件,打开时再读回来,格式、段落、内联样式基本都能保留。
// 保存:把 FlowDocument 序列化为 XAML 文件 public void SaveToXaml(FlowDocument doc, string path) { using (var fs = new FileStream(path, FileMode.Create)) { // 第三个参数控制是否保留缩进,调试时可设 true XamlWriter.Save(doc, fs); } } // 打开:从 XAML 文件还原 FlowDocument public FlowDocument LoadFromXaml(string path) { using (var fs = new FileStream(path, FileMode.Open)) { return XamlReader.Load(fs) as FlowDocument; } }逻辑说明:XamlWriter.Save会把 FlowDocument 的整个对象树序列化,包括你自定义的附加属性(前提是类型可序列化)。参数上,FileMode.Create会覆盖同名文件,如果要保留历史版本得自己加备份逻辑。注意 XAML 序列化出来的文件体积偏大,而且和 WPF 版本有一定耦合,跨版本读取偶尔会报类型找不到,这是它的边界。
3.2 RTF 互通:和 Word 交换文件的现实选择
如果需求是“用户能把 Word 里写好的内容粘进来,也能把编辑结果导出成 Word 能打开的格式”,那 RTF 比 XAML 更合适。WPF 的 RichTextBox 原生支持 RTF 的加载和保存,通过TextRange的Load和Save方法,指定DataFormats.Rtf即可。
// 导出为 RTF,Word 可直接打开 public void SaveToRtf(RichTextBox rtb, string path) { var range = new TextRange(rtb.Document.ContentStart, rtb.Document.ContentEnd); using (var fs = new FileStream(path, FileMode.Create)) { // 第二个参数指定数据格式为 RTF range.Save(fs, DataFormats.Rtf); } } // 从 RTF 文件导入 public void LoadFromRtf(RichTextBox rtb, string path) { var range = new TextRange(rtb.Document.ContentStart, rtb.Document.ContentEnd); using (var fs = new FileStream(path, FileMode.Open)) { range.Load(fs, DataFormats.Rtf); } }逻辑说明:TextRange代表文档里的一段范围,ContentStart到ContentEnd就是全文。DataFormats.Rtf是 WPF 内置支持的格式,不需要额外引用库。参数上,RTF 对复杂排版(比如嵌套表格、文本框、公式)的支持有限,导入后可能出现样式丢失,这是格式本身的限制,不是代码 bug。如果项目里涉及公式图片转 Word 这类需求,RTF 这条路走不通,得考虑 OpenXML SDK 直接操作 docx。
3.3 两种格式怎么选
| 对比项 | XAML 序列化 | RTF |
|---|---|---|
| 实现难度 | 低,几行代码 | 低,TextRange 原生支持 |
| 格式保真度 | 高,WPF 对象完整保留 | 中,复杂排版会丢 |
| 与 Word 互通 | 不能直接打开 | 可以,Word 原生支持 |
| 文件体积 | 偏大 | 较小 |
| 适用场景 | 应用内部存档、草稿恢复 | 导入导出、和外部交换 |
常见做法是内部草稿用 XAML,导出给用户用 RTF 或 docx。如果非要和 Word 深度互通,建议直接上 OpenXML SDK,但那是另一个工程量级了。
4. 避坑与排查:WPF 富文本编辑器最容易翻车的五个地方
这份 Demo 能跑起来不代表你改的时候不踩坑。下面这几条是我在实际项目里反复遇到的,按“现象 → 原因 → 解决”整理,照着排查能省不少时间。
4.1 输入中文时卡顿或候选框错位
现象:用拼音输入法打字,候选框不跟随光标,或者每敲一个字界面就卡一下。 原因:RichTextBox 的 TextChanged 事件里做了重活,比如每次都重新序列化整个 FlowDocument,或者每次都刷新工具栏所有按钮状态。 解决:把重活从 TextChanged 里挪出去,用 DispatcherTimer 做防抖,或者只在保存和切换选区时序列化。候选框错位通常是自定义了控件模板但没保留原生的 Adorner 层,检查一下有没有覆盖 RichTextBox 的默认模板。
4.2 粘贴外部内容后格式全乱
现象:从网页或 Word 复制一段文字粘进来,字体、行距、颜色全变了,甚至带进来一堆隐藏样式。 原因:剪贴板里的数据包含 HTML 或 RTF 格式,RichTextBox 默认会尽量保留原格式。 解决:拦截DataObject.Pasting事件,只取纯文本或者做格式清洗。常见做法是:
private void RichTextBox_Pasting(object sender, DataObjectPastingEventArgs e) { // 只允许纯文本粘贴,避免外部样式污染 if (e.DataObject.GetDataPresent(DataFormats.UnicodeText)) { string text = e.DataObject.GetData(DataFormats.UnicodeText) as string; e.DataObject = new DataObject(DataFormats.UnicodeText, text); } else { e.CancelCommand(); } }参数说明:DataFormats.UnicodeText保证拿到的是纯文本,e.CancelCommand()用于拒绝不支持的格式。如果产品要求保留部分格式,可以改成取 RTF 后做白名单过滤。
4.3 撤销重做栈被意外清空
现象:用户编辑到一半,点了一下工具栏的某个格式按钮,再按 Ctrl+Z 发现之前的操作全没了。 原因:直接给rtb.Document赋新对象会重置撤销栈,或者在代码里手动改了 FlowDocument 结构但没走EditingCommands。 解决:所有格式操作尽量通过EditingCommands或者TextRange.ApplyPropertyValue来做,不要整体替换 Document。如果确实需要替换,先提示用户保存。
4.4 大数据量文档打开慢甚至假死
现象:打开一个几万字的文档,界面卡住十几秒。 原因:FlowDocument 在 UI 线程上一次性构建,加上 XAML 反序列化本身就不快。 解决:分页加载或者延迟加载,先把文档骨架显示出来,内容分块填充。另一个思路是打开时禁用拼写检查和自动格式,这两个功能在大文档上开销很大。
4.5 高 DPI 下字体模糊或工具栏错位
现象:在 4K 屏上,编辑区文字发虚,工具栏图标位置偏移。 原因:WPF 默认的 DPI 感知模式和应用清单里的设置不匹配。 解决:在 app.manifest 里开启 PerMonitorV2 DPI 感知,同时检查图片资源有没有提供多倍图。字体模糊有时候是因为用了TextOptions.TextFormattingMode="Display",改成Ideal会清晰一些,但渲染开销略增。
5. 进阶玩法:把编辑器接进真实业务流程
Demo 跑通只是起点,真正要落地,得把它嵌进你的业务里。这一章讲两个具体方向:一是怎么把编辑内容和数据绑定打通,二是怎么用命令模式扩展自定义功能。
5.1 让编辑内容参与数据绑定和校验
很多业务场景下,编辑区的内容需要实时同步到 ViewModel,参与保存、校验、联动。前面提到的附加属性只能解决 Document 挂载,要做到内容变更通知,还得监听 TextChanged 并做节流。
// 在 ViewModel 里暴露一个可绑定的文本属性 private string _plainText; public string PlainText { get => _plainText; set { _plainText = value; OnPropertyChanged(); } } // 控件侧:TextChanged 时提取纯文本,节流后回写 private DispatcherTimer _throttleTimer; private void RichTextBox_TextChanged(object sender, TextChangedEventArgs e) { _throttleTimer?.Stop(); _throttleTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _throttleTimer.Tick += (s, args) => { _throttleTimer.Stop(); var range = new TextRange(rtb.Document.ContentStart, rtb.Document.ContentEnd); ViewModel.PlainText = range.Text; }; _throttleTimer.Start(); }逻辑说明:用 300 毫秒的 DispatcherTimer 做防抖,避免每次按键都触发绑定更新。参数上,Interval 可以根据文档大小调整,大文档建议放到 500 毫秒以上。注意range.Text拿到的是纯文本,格式信息会丢,如果需要保留格式,得序列化成 XAML 或 RTF 再存。
5.2 用命令模式扩展自定义按钮
WPF 的RoutedCommand和CommandBinding是扩展工具栏的标准姿势。比如加一个“插入当前时间”的按钮:
// 定义命令 public static readonly RoutedUICommand InsertTimeCommand = new RoutedUICommand("插入时间", "InsertTime", typeof(MainWindow)); // 在窗口构造函数里绑定命令和执行逻辑 CommandBindings.Add(new CommandBinding(InsertTimeCommand, (s, e) => { var rtb = editor; // 在光标位置插入当前时间 rtb.CaretPosition.InsertTextInRun(DateTime.Now.ToString("yyyy-MM-dd HH:mm")); }));逻辑说明:RoutedUICommand的好处是可以自动冒泡到父级元素,方便做全局快捷键。参数上,InsertTextInRun会在当前 Run 里插入文本,如果光标跨段落,需要先判断CaretPosition所在的段落结构。这个模式可以复制到任何自定义功能上,比如插入表格、插入图片、导出 PDF。
5.3 一个我自己的习惯
早些年我接手一个文档编辑模块,图省事直接在代码后台操作 RichTextBox,结果后来要加单元测试,发现逻辑全耦合在 UI 里,重构花了两周。从那以后我每次拿到这类 Demo,第一件事就是确认它的 MVVM 分层是不是干净——View 只做渲染和事件转发,逻辑尽量往 ViewModel 和 Service 里挪。这份代码在这点上做得还算克制,工具栏命令和文档操作基本都走了绑定,改起来不至于牵一发动全身。如果你打算拿它做二次开发,建议先把 Document 的序列化和反序列化抽成独立服务,后面换存储格式或者加版本兼容会轻松很多。
希望这份拆解能帮到你,少走点我当年走过的弯路。
本文还有配套的精品资源,点击获取