ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WPF原生控件仿Word富文本编辑器:FlowDocument与命令绑定

WPF原生控件仿Word富文本编辑器:FlowDocument与命令绑定

简介:面向希望基于 WPF 构建类 Word 文本编辑环境的 .NET 开发者,这套开源富文本编辑器示例提供可直接运行的解决方案与可复用核心组件,覆盖基础文本编辑、查找替换、HTML 源码查看、打印、纯文本导出、插入图片和表格等常见能力。压缩包共 289 个文件,约 753KB,以 cs 源码、xaml/baml 界面资源、gif/png 图片素材和 dll 程序集为主,并包含 sln、csproj 等工程配置,便于梳理项目结构与依赖关系。已有 2875 人学习或下载。读者可借助 HtmlEditor.Demo 了解编辑器界面与交互逻辑,通过 HtmlEditor 组件研究富文本处理、图像与表格数据的组织方式,并在此基础上扩展格式化选项、文件格式支持或第三方库集成,适合作为 WPF 桌面端文档编辑功能的学习与二次开发起点。

1. 从「原生控件能不能撑住」说起

接到「仿 Word 的 WPF 富文本编辑器」需求时,多数人第一反应是找商业控件,觉得原生RichTextBox太玩具。我一开始也这么判断,直到用原生控件把一份带编号列表、插图、行距设置的文档完整跑通,才发现瓶颈从来不在控件本身,而在于有没有把FlowDocument这套文档模型吃透。这份开源 demo 做的事情很朴素:用 WPF 原生控件搭出一个能加粗、能调字号、能插图片、能存 RTF 的编辑区,工具栏和编辑区之间靠路由命令解耦,没有任何第三方依赖。它适合两类人:一类是想给桌面端产品加个简易排版能力、又不想背商业授权包袱的开发者;另一类是刚上手 WPF、想找一个完整例子把依赖属性、命令绑定、文档序列化串起来的新手。它不打算替代 Word,它想做到的是把「编辑器」这件事拆到你能自己动手改的程度。

2. 技术选型与工程结构:原生控件撑起仿 Word 的壳

2.1 RichTextBox 与第三方控件的取舍

WPF 里做富文本,可选路线其实只有三条:原生RichTextBox加FlowDocument、嵌WebView2跑一段前端编辑器、买商业控件。三条路的差别不在功能多少,而在你后续要付出什么代价。

原生路线的能力边界很清晰。FlowDocument是一棵可序列化的文档树,段落、行内元素、列表、表格都是强类型对象,你能直接用 LINQ 遍历、直接改属性、直接挂事件。TextRange提供了对任意选区做属性读写的能力,EditingCommands自带加粗、斜体、下划线、项目符号、编号、缩进、对齐这一整套命令,连撤销栈都是控件内置的。对一个「仿 Word 的 demo」来说,这套东西的完成度已经超出预期。

它的短板同样明显。表格编辑能力弱,合并单元格、跨页表格基本要自己写;图片只能靠InlineUIContainer塞进去,排版控制粒度粗;没有分页视图,长文档滚动时是一次性布局,几万字以上会明显卡;RTF 导出对图片和复杂样式的保真度有限。这些是原生控件的天花板,选它之前得认。

第三方控件的优势是这些坑别人已经踩完了,表格、分页、页眉页脚、PDF 导出都是现成的。代价是授权费用、包体积,以及最要命的一点——出问题时你只能提工单,看不到源码,排查起来像个黑匣子。WebView2那条路则是把编辑能力交给前端生态,功能上限最高,但 WPF 侧和 JS 侧要维护一套消息协议,文档模型存在两份,序列化、撤销、光标状态同步都是长期成本。

我一般的判断标准是:如果产品只需要「带格式的文本 + 少量图片 + 导出」,原生控件完全够用,改起来也最自由;如果文档里表格占比超过三成,或者需要严格的打印分页,趁早换路线,别硬扛。这份 demo 走的是第一条路,它的价值不在于功能齐全,而在于每一个功能都摊开在你眼前,没有黑盒。

2.2 目录结构与三层分层

工程结构上,这类项目最常见的做法是按职责切成四块:应用层放窗口和启动逻辑,控件层放编辑区和工具栏这两个UserControl,核心层放命令定义、文档读写、剪贴板清洗这些纯逻辑,样式层单独出一个资源字典项目方便换肤。这么切的好处是核心层不引用任何Window,将来搬到别的宿主里(比如嵌进插件)不用改代码。

WpfRichEditor/ ├── WpfRichEditor.sln ├── src/ │ ├── WpfRichEditor.App/ # 启动与主窗口 │ │ ├── App.xaml │ │ ├── MainWindow.xaml │ │ └── MainWindow.xaml.cs │ ├── WpfRichEditor.Controls/ # 可复用控件 │ │ ├── RichEditor.xaml # 编辑区 UserControl │ │ ├── RichEditor.xaml.cs │ │ ├── EditorToolbar.xaml # 顶部工具栏 │ │ └── EditorToolbar.xaml.cs │ ├── WpfRichEditor.Core/ # 无 UI 依赖的逻辑 │ │ ├── Commands/EditorCommands.cs # 自定义 RoutedCommand │ │ ├── Documents/DocumentIO.cs # RTF / XAML 存取 │ │ └── Services/ClipboardSanitizer.cs │ └── WpfRichEditor.Themes/ # 资源字典 │ └── EditorStyles.xaml └── tests/ └── WpfRichEditor.Tests/

RichEditor这个UserControl内部只放一个RichTextBox,对外暴露Document依赖属性和几个方法(应用样式、加载、保存),工具栏不直接持有RichTextBox引用,而是通过CommandTarget找目标。这条约束很关键,它决定了后面加第二个编辑区时你要不要重写工具栏。

2.3 环境准备与第一次运行

项目是标准的 WPF 工程,目标框架写net8.0-windows(老一点的项目可能是net6.0-windows或net48),关键是UseWPF必须为true,否则 XAML 编译器不会介入,InitializeComponent直接找不到。

<!-- WpfRichEditor.Controls.csproj --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- WPF 必须锁 windows 目标框架,跨平台 SDK 风格项目里这是硬性条件 --> <TargetFramework>net8.0-windows</TargetFramework> <UseWPF>true</UseWPF> <Nullable>enable</Nullable> <LangVersion>latest</LangVersion> </PropertyGroup> </Project>

命令行验证比开 Visual Studio 快,先确认 SDK 装没装对,再编译运行:

# 确认本机有 8.0 或 6.0 的 SDK dotnet --list-sdks # 还原 + 编译整个解决方案 dotnet build WpfRichEditor.sln -c Debug # 直接跑主程序,不用开 IDE dotnet run --project src/WpfRichEditor.App/WpfRichEditor.App.csproj

三个命令里最容易出问题的是第一条。如果dotnet --list-sdks只列出 6.0 而项目写的是net8.0-windows,编译会报NETSDK1045,提示当前 SDK 不支持目标框架;反过来项目是net48的话,就必须用 Visual Studio 的 MSBuild,dotnet build会因为找不到Microsoft.NETFramework.ReferenceAssemblies失败。这两种报错信息都很直白,照着改目标框架或者补装 SDK 即可,不算坑。

跑起来之后你会看到一个典型的工具栏加编辑区布局。工具栏从上到下依次是字体、字号、加粗斜体下划线、字体颜色、对齐方式、列表、插入图片、保存和打开。每一项都能点,点到没选区的状态时按钮会变灰——这个灰不是写死的,是命令的CanExecute在起作用,第 4 章会讲清楚它是怎么联动起来的。

3. 文档模型拆解:FlowDocument 树、TextRange 与样式落盘

3.1 Block 与 Inline 树的结构

想改这个编辑器,第一件事是把FlowDocument的树结构在脑子里建起来。它只有两层节点类型:Block是块级元素,独占一行;Inline是行内元素,可以并排。FlowDocument.Blocks是整篇文档的根集合,里面装Paragraph(段落)、Section(分组)、List(列表)、Table(表格)、BlockUIContainer(塞任意控件)。Paragraph.Inlines里装Run(一段同格式文本)、Span(可嵌套的容器)、Bold/Italic/Underline(语义化行内元素)、Hyperlink、InlineUIContainer(塞图片或任意 UI 元素)、LineBreak。

理解这层结构之后,很多「为什么改不动」的问题就自解了。比如用户选中一段文字点加粗,实际发生的是:TextRange把选区拆成若干Run,把FontWeight属性ApplyPropertyValue到每个Run上,Bold元素只是FontWeight=Bold的语法糖。再比如你想给整篇文档统一行距,操作对象是Paragraph而不是Run,因为行距是块级属性。

遍历这棵树最常用的写法是递归,比如统计段落数、批量改对齐、导出纯文本都会用到:

// 递归展开所有块级容器,只挑出 Paragraph private static IEnumerable<Paragraph> WalkParagraphs(BlockCollection blocks) { foreach (var block in blocks) { switch (block) { case Paragraph p: yield return p; break; case Section section: // Section 是一层分组容器,里面还能再套 Block foreach (var child in WalkParagraphs(section.Blocks)) yield return child; break; case List list: // 列表项的正文在 ListItem.Blocks 里,注意别漏 foreach (var item in list.ListItems) foreach (var child in WalkParagraphs(item.Blocks)) yield return child; break; case Table table: foreach (var row in table.RowGroups.SelectMany(g => g.Rows)) foreach (var cell in row.Cells) foreach (var child in WalkParagraphs(cell.Blocks)) yield return child; break; } } }

这段代码的关键在于「容器要展开,叶子要产出」。Section、List、Table都是容器,自身不承载文本,必须继续往下走;Paragraph是叶子,产出后不再递归。实际项目里我见过有人只遍历Document.Blocks一层,结果列表和表格里的段落全部漏掉,导出纯文本时内容缺一半,排查半天才发现是遍历深度的问题。这个方法后续会被文档大纲、查找替换、全文统计反复复用,值得单独抽成一个静态工具类。

3.2 选中文本改样式:ApplyPropertyValue 的完整链路

工具栏上每个格式按钮,最终都落到同一个方法上:TextRange.ApplyPropertyValue。它接收一个依赖属性和一个值,把属性写进选区覆盖的所有TextElement。字体、字号、前景色、背景色、加粗、斜体、下划线全走这条链路,区别只是属性名不同。

// 统一的样式应用入口,工具栏所有格式按钮都调它 private void ApplyToSelection(DependencyProperty property, object value) { var selection = _editor.Selection; // 空选区时没有可写的 TextElement,直接写会静默失败 // 这里的处理是把值暂存,等用户真正输入时再补,避免「点了没反应」的错觉 if (selection.IsEmpty) { _pendingStyle = (property, value); return; } // 整段操作包在 BeginChange/EndChange 里,撤销栈只留一个记录点 _editor.BeginChange(); try { selection.ApplyPropertyValue(property, value); } finally { _editor.EndChange(); } // 焦点必须还给编辑区,否则用户按方向键发现光标不在文档里 _editor.Focus(); } // 三个高频调用的封装 public void ToggleBold() => ApplyToSelection(TextElement.FontWeightProperty, _editor.Selection.GetPropertyValue(TextElement.FontWeightProperty) is FontWeight w && w == FontWeights.Bold ? FontWeights.Normal : FontWeights.Bold); public void SetFontSize(double size) => ApplyToSelection(TextElement.FontSizeProperty, size); public void SetForeground(Brush brush) => ApplyToSelection(TextElement.ForegroundProperty, brush);

逐段说清楚。_editor.Selection返回的是TextSelection,它继承自TextRange,所以ApplyPropertyValue和GetPropertyValue都能直接用。加粗的切换逻辑先用GetPropertyValue读回当前值再取反,这是「按钮状态跟随光标」的基础——注意读回的值可能是DependencyProperty.UnsetValue,表示选区里格式不统一,这时候按「未加粗」处理比较符合直觉。

BeginChange/EndChange这一对很容易被忽略,但它直接决定用户体验。不加的话,一次「给整段设置字号」会在撤销栈里留下几十条记录,用户按一次 Ctrl+Z 只回退一点点,得连按十几下才能回到原样,这属于典型的「后悔药不够吃」。包起来之后,整批操作合并成一个撤销单元,一次 Ctrl+Z 全部回退。

最后那句_editor.Focus()是血泪经验。工具栏是独立控件,点按钮时焦点会跑到按钮上,选区在视觉上还在,但TextSelection的锚点可能已经失效,用户接着敲字会插到奇怪的位置。所有修改文档的命令执行完都要把焦点还给编辑区。

3.3 段落级排版:对齐、行距、缩进与列表

行内属性管字符,段落属性管整块。对齐、行距、首行缩进、段前段后距,这些属性的宿主是Paragraph,不是Run。所以设置方式有两种:选区跨段落时用ApplyPropertyValue,属性写Paragraph.TextAlignmentProperty;只处理光标所在段落时直接拿CaretPosition.Paragraph改。

// 段落对齐:选区覆盖多个段落时,每个 Paragraph 都会被写一遍 public void SetAlignment(TextAlignment alignment) => ApplyToSelection(Paragraph.TextAlignmentProperty, alignment); // 行距必须成对设置,只改 LineHeight 不生效 public void SetLineHeight(double height) { var paragraph = _editor.CaretPosition.Paragraph; if (paragraph is null) return; paragraph.LineHeight = height; // 单位是 DIP,24 约等于 18pt paragraph.LineStackingStrategy = LineStackingStrategy.BlockLineHeight; // 按固定值堆叠 } // 首行缩进,负值表示悬挂缩进 public void SetFirstLineIndent(double indent) => ApplyToSelection(Paragraph.TextIndentProperty, indent);

LineHeight的单位是设备无关像素(DIP),1 DIP 等于 1/96 英寸,所以 24 大约是 18 磅,中文正文常用 24 到 30 之间。真正需要注意的是LineStackingStrategy:默认值是MaxHeight,意思是行高取该行内所有元素的最大高度,此时LineHeight基本不起作用;必须改成BlockLineHeight才会按你设的固定值算。这个属性组合是个经典陷阱,不少人改完行距发现没反应,怀疑控件有 bug,其实就差这一行。

列表用的是内置命令,不用自己拼List对象:

// 项目符号和编号都由 EditingCommands 提供,内部会处理 List 容器的创建与拆分 public void ToggleBullets() => EditingCommands.ToggleBullets.Execute(null, _editor); public void ToggleNumbering() => EditingCommands.ToggleNumbering.Execute(null, _editor); // 增加/减少缩进同理 public void IncreaseIndent() => EditingCommands.IncreaseIndentation.Execute(null, _editor);

这几个命令的第二个参数必须是RichTextBox本身,传null或者传Window都会抛InvalidOperationException,因为命令内部要靠它拿Document和Selection。这是新手最常撞的一堵墙,报错信息只说「命令目标无效」,不会告诉你要传谁。

3.4 RTF 与 XAML:两条存取路线的差别

保存和打开是这类 demo 的必备功能,WPF 提供了一条极简的路径:TextRange.Save和TextRange.Load,靠DataFormats决定序列化格式。整个实现不超过二十行。

public static class DocumentIO { // 保存为 RTF,兼容 Word / WPS,但图片和复杂样式会丢 public static void SaveRtf(FlowDocument document, string path) { var range = new TextRange(document.ContentStart, document.ContentEnd); using var stream = File.Create(path); range.Save(stream, DataFormats.Rtf); } // 保存为 XAML 包,图片以资源形式打进 zip,保真度最高 public static void SaveXamlPackage(FlowDocument document, string path) { var range = new TextRange(document.ContentStart, document.ContentEnd); using var stream = File.Create(path); range.Save(stream, DataFormats.XamlPackage); } // 加载时复用已有文档实例,不要 new 一个 FlowDocument 再赋值 public static void Load(FlowDocument document, string path) { var range = new TextRange(document.ContentStart, document.ContentEnd); using var stream = File.OpenRead(path); var format = Path.GetExtension(path).ToLowerInvariant() switch { ".rtf" => DataFormats.Rtf, ".xaml" => DataFormats.XamlPackage, _ => DataFormats.Text }; range.Load(stream, format); } }

四种格式的取舍用一张表说清楚更省事:

格式常量是否带图片能否被 Word 打开体积适用场景
RTFDataFormats.Rtf部分支持,保真度低是中交付给外部、需要跨软件打开
XAMLDataFormats.Xaml否,图片直接丢否小内部调试、看文档结构
XAML 包DataFormats.XamlPackage是,图片打进 zip否大本地存档、二次加载
纯文本DataFormats.Text否是最小导出、复制到别处

Save的第一个参数是Stream而不是路径,这个设计是有意的:它内部按字节写,不经过字符串编码转换,所以不会出现中文乱码。反过来说,如果你图省事用StreamWriter包一层再传进去,中文必然乱码,因为 RTF 里的中文是\uNNNN转义序列,被编码转换一次就废了。这一点在第 5 章还会展开。

Load那段代码里有一句注释值得再强调一遍:复用已有的FlowDocument实例。很多人写_editor.Document = new FlowDocument()再往里灌内容,看起来也能跑,但撤销栈会被整个重置,用户打开文件后按 Ctrl+Z 什么都不会发生。原因在第 5 章细说。

4. 工具栏与命令层:按钮怎么真正驱动编辑区

4.1 EditingCommands 直接绑定

WPF 的命令系统是这套编辑器的骨架。加粗、斜体、下划线、左对齐、居中、右对齐、两端对齐、项目符号、编号、增加缩进、减少缩进、剪切、复制、粘贴、撤销、重做——这些全都在EditingCommands和ApplicationCommands里预置好了,工具栏按钮只需要一行 XAML 就能接上。

<ToolBarTray DockPanel.Dock="Top"> <ToolBar> <!-- CommandTarget 必须指向 RichTextBox,否则命令找不到执行目标,按钮全程灰 --> <Button Command="EditingCommands.ToggleBold" CommandTarget="{Binding ElementName=Editor}" ToolTip="加粗 (Ctrl+B)"> <TextBlock Text="B" FontWeight="Bold"/> </Button> <Button Command="EditingCommands.ToggleItalic" CommandTarget="{Binding ElementName=Editor}"> <TextBlock Text="I" FontStyle="Italic"/> </Button> <Button Command="EditingCommands.ToggleUnderline" CommandTarget="{Binding ElementName=Editor}"> <TextBlock Text="U" TextDecorations="Underline"/> </Button> <Separator/> <Button Command="EditingCommands.AlignLeft" CommandTarget="{Binding ElementName=Editor}" Content="左对齐"/> <Button Command="EditingCommands.AlignCenter" CommandTarget="{Binding ElementName=Editor}" Content="居中"/> <Button Command="EditingCommands.AlignJustify" CommandTarget="{Binding ElementName=Editor}" Content="两端"/> </ToolBar> </ToolBarTray>

CommandTarget是这段 XAML 的灵魂。WPF 的路由命令会从焦点元素开始往上冒泡找CommandBinding,如果工具栏按钮和编辑区不在同一条视觉树路径上,或者焦点根本不在编辑区,命令就找不到目标,CanExecute返回false,按钮变灰。显式写CommandTarget等于告诉命令「去这个控件上执行」,绕开了焦点依赖。

这套写法的好处是零代码。加粗、斜体、对齐、缩进、剪切粘贴、撤销重做,一行 XAML 顶几十行事件处理。而且CanExecute是自动的:光标在普通段落里,ToggleBullets可用;光标已经在列表项里,DecreaseIndentation才可用;选区为空时Cut自动变灰。这些状态全由控件内部维护,你不需要写一行判断逻辑。

4.2 自定义 RoutedCommand 支撑字号、字体、颜色

内置命令覆盖不到的地方就得自己定义。字号下拉框、字体下拉框、字体颜色选择器这三个是刚需,EditingCommands里没有对应的命令,得走自定义RoutedUICommand加CommandBinding的路子。

// 自定义命令集中放一个静态类,方便 XAML 里用 x:Static 引用 public static class EditorCommands { public static readonly RoutedUICommand ApplyFontSize = new RoutedUICommand("应用字号", nameof(ApplyFontSize), typeof(EditorCommands)); public static readonly RoutedUICommand ApplyFontFamily = new RoutedUICommand("应用字体", nameof(ApplyFontFamily), typeof(EditorCommands)); public static readonly RoutedUICommand ApplyForeground = new RoutedUICommand("应用字体颜色", nameof(ApplyForeground), typeof(EditorCommands)); public static readonly RoutedUICommand InsertImage = new RoutedUICommand("插入图片", nameof(InsertImage), typeof(EditorCommands), new InputGestureCollection { new KeyGesture(Key.I, ModifierKeys.Control | ModifierKeys.Shift) }); }

命令定义好之后要在编辑区上挂CommandBinding,Executed负责干活,CanExecute负责灰不灰:

<RichTextBox x:Name="Editor" ...> <RichTextBox.CommandBindings> <CommandBinding Command="{x:Static core:EditorCommands.ApplyFontSize}" Executed="OnApplyFontSize"/> <CommandBinding Command="{x:Static core:EditorCommands.InsertImage}" Executed="OnInsertImage"/> </RichTextBox.CommandBindings> </RichTextBox>
private void OnApplyFontSize(object sender, ExecutedRoutedEventArgs e) { // 参数从工具栏传进来,命令本身不关心 UI 长什么样 if (e.Parameter is not double size || size <= 0) return; ApplyToSelection(TextElement.FontSizeProperty, size); } private void OnInsertImage(object sender, ExecutedRoutedEventArgs e) { var dialog = new OpenFileDialog { Filter = "图片文件|*.png;*.jpg;*.jpeg;*.bmp", Multiselect = false }; if (dialog.ShowDialog() != true) return; var bitmap = new BitmapImage(new Uri(dialog.FileName)); // 图片必须包在 InlineUIContainer 里才能进入 FlowDocument 的行内流 var image = new Image { Source = bitmap, Stretch = Stretch.Uniform }; var container = new InlineUIContainer(image, _editor.CaretPosition); container.BaselineAlignment = BaselineAlignment.Center; _editor.CaretPosition = container.ElementEnd; _editor.Focus(); }

ExecutedRoutedEventArgs.Parameter这个口子很实用,它让同一个命令能接不同来源的参数:字号下拉框传double,字体下拉框传FontFamily,颜色选择器传Brush。工具栏侧只需要CommandParameter="{Binding SelectedItem, ElementName=FontSizeBox}"就接上了,命令实现里做一次类型判断即可。

InlineUIContainer那段有个细节:构造时传CaretPosition会直接把图片插到光标处,不需要先操作Inlines.Add再调整位置。插入完把光标挪到container.ElementEnd,用户接着敲字就会在图片后面,而不是跑到图片前面去。这是插入图片最容易翻车的地方,光标位置不对,用户体感就是「图片插进去了但打字跑到奇怪的地方」。

4.3 撤销重做与命令可用状态联动

RichTextBox自带撤销栈,IsUndoEnabled默认是true,UndoLimit默认值是-1(不限制),实际项目里建议改成一个有限值,比如 100,否则长时间编辑超大文档内存会一路涨。撤销栈的粒度由BeginChange/EndChange控制,这对方法在第 3 章已经用过,这里补充几个容易踩的点。

// 撤销重做直接转发给控件,不要自己维护栈 private void OnUndo(object sender, ExecutedRoutedEventArgs e) => _editor.Undo(); private void OnRedo(object sender, ExecutedRoutedEventArgs e) => _editor.Redo(); // 工具栏按钮的可用状态跟着控件的 CanUndo / CanRedo 走 private void RefreshUndoState() { BtnUndo.IsEnabled = _editor.CanUndo; BtnRedo.IsEnabled = _editor.CanRedo; }

第一,不要在BeginChange和EndChange之间再嵌套一对。WPF 支持嵌套,但嵌套层数越多,撤销粒度越粗,最后可能出现「改十个段落只留一个撤销点」的情况,用户想单独回退某一处就做不到了。批量操作包一层就够。

第二,任何绕过TextRange直接改文档对象的操作,撤销栈都记不住。比如遍历所有Paragraph直接改TextAlignment,控件不知道你改了,Ctrl+Z 无法回退。要让它可撤销,得把这段修改包在_editor.BeginChange()里;要让它不可撤销(比如加载文档时批量规整格式),就在加载前把IsUndoEnabled设为false,加载完再打开,顺手_editor.Document保持不变。

第三,命令的可用状态是自动的,但工具栏如果是自绘按钮而不是Button加Command绑定,就要手动在SelectionChanged、TextChanged、CaretPosition变化时刷新。混用两种方式是最容易出 bug 的写法:一半按钮自动灰,一半按钮永远可点,测试提的 bug 单上就写「格式按钮状态混乱」。我一般统一走Command绑定,实在需要自绘的地方才手动同步,尽量减少状态源。

5. 避坑与排查:五条踩出来的记录

5.1 从 Word 粘贴后整篇样式崩掉

现象:用户从 Word 复制一段带样式的文字粘进来,编辑区里原本设置好的字号、字体、行距全部被覆盖,有时候还会冒出奇怪的页边距,撤销也回不到粘贴前的状态。

原因:剪贴板里同时存在DataFormats.Rtf、DataFormats.Xaml、DataFormats.UnicodeText等多种格式,RichTextBox默认会优先挑格式最丰富的那种,把外来 RTF 里的一堆样式表和文档级属性一并吞进来。Word 导出的 RTF 带着它自己的样式定义,进来之后和当前文档的属性打架,谁后写谁赢。

解决:拦截DataObject.Pasting事件,在粘贴真正发生前把数据对象换掉,只保留纯文本,或者只保留最基础的格式。

// 在编辑区的构造函数里挂一次 _editor.AddHandler(DataObject.PastingEvent, new DataObjectPastingEventHandler(OnPasting)); private void OnPasting(object sender, DataObjectPastingEventArgs e) { // 只接受纯文本,把外来的 RTF / XAML 全部丢掉 if (e.SourceDataObject.GetDataPresent(DataFormats.UnicodeText, true)) { var text = (string)e.SourceDataObject.GetData(DataFormats.UnicodeText); e.DataObject = new DataObject(DataFormats.UnicodeText, text); return; } // 连纯文本都没有,直接取消这次粘贴 e.CancelCommand(); }

如果想保留一部分格式,就不要整体替换数据对象,而是把 RTF 取出来做一次清洗(去掉\paperw、\stylesheet这类文档级控制字),再用TextRange.Load手动插到光标处。这条路更细但代码量翻好几倍,demo 阶段用纯文本方案完全够。

5.2 长文档输入明显卡顿

现象:文档写到一两万字、段落数上千之后,每次敲键都有几百毫秒延迟,滚动也发涩,任务管理器里进程内存一路涨。

原因:FlowDocument是流式布局,没有虚拟化,任何一次内容变更都会触发受影响范围内重新测量和排版。如果RichTextBox被放在ScrollViewer里,外层滚动容器给它的可用高度是无限大,它会尝试一次性布局整篇文档,性能直接崩。另一种常见情况是PageWidth没设,文档宽度跟着宿主走,窗口一拉宽,所有段落重新折行。

解决:把RichTextBox从外层ScrollViewer里拿出来,用自身的VerticalScrollBarVisibility="Auto";给FlowDocument设一个固定的PageWidth和PagePadding,让折行结果稳定;段落数特别多时按章节拆文档,或者加一个「只渲染当前可见页」的分页视图。这三条里第一条见效最快,也最容易被忽略。

<!-- 错误写法:外层套 ScrollViewer,编辑区高度无限,布局全量跑 --> <ScrollViewer> <RichTextBox x:Name="Editor"/> </ScrollViewer> <!-- 正确写法:让编辑区自己管滚动,文档宽度固定 --> <RichTextBox x:Name="Editor" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> <FlowDocument PageWidth="794" PagePadding="72,56,72,56"> <Paragraph/> </FlowDocument> </RichTextBox>

PageWidth="794"对应 A4 纸在 96 DPI 下的宽度,左右边距 72 DIP 约等于 0.75 英寸,这套数值和 Word 的默认版心基本对齐,视觉上比较自然。

5.3 打开文档后撤销键失效

现象:程序启动后编辑几下,Ctrl+Z 正常;点「打开」加载一个文件之后再编辑,Ctrl+Z 就不动了,撤销按钮也一直是灰的。

原因:加载时写了_editor.Document = new FlowDocument(),把整个文档对象换掉了。撤销栈是挂在RichTextBox上、跟着Document实例走的,文档一换,栈直接清空并重置,旧内容的历史记录全部丢失。

解决:复用同一个FlowDocument实例,用TextRange覆盖它的内容;如果确实需要重置撤销栈(比如加载后不希望用户撤销回上一个文件),显式在加载前后切IsUndoEnabled,让行为可控。

private void OpenDocument(string path) { // 加载期间关掉撤销,避免「加载」本身变成一条可撤销记录 _editor.IsUndoEnabled = false; try { DocumentIO.Load(_editor.Document, path); // 复用实例,不清空栈引用 } finally { _editor.IsUndoEnabled = true; _editor.CaretPosition = _editor.Document.ContentStart; _editor.Focus(); } }

5.4 中文输入法期间选区丢失、字符顺序错乱

现象:用拼音输入法打字时,如果中途点了工具栏的加粗或者字号,候选框会消失,已经输入的拼音被打断,有时候字符还会插到光标前面去。

原因:输入法组合字符串(composition string)还没上屏时,TextSelection处于一种中间状态,此时调用ApplyPropertyValue或让编辑区失焦,会强行结束组合,导致输入法缓冲区的内容丢失或错位。工具栏按钮点击本身也会让RichTextBox失焦,而 IME 对失焦非常敏感。

解决:在工具栏按钮的命令执行前判断输入法状态,组合期间直接拒绝执行;或者不判断,但保证命令执行完立刻把焦点还回来,让 IME 有机会恢复。

private void OnApplyFontSize(object sender, ExecutedRoutedEventArgs e) { // 输入法正在组合时不要动文档,否则拼音会被打断 if (InputMethod.Current.ImeState == InputMethodState.On && InputMethod.Current.ImeConversionMode.HasFlag(ImeConversionModeValues.Native)) { // 这里选择直接返回,也可以给个提示让用户先上屏 return; } if (e.Parameter is not double size) return; ApplyToSelection(TextElement.FontSizeProperty, size); }

更稳妥的做法是在工具栏的PreviewMouseDown里做判断并e.Handled = true,从源头阻止焦点转移。这类问题在测试阶段很容易漏,因为测试同学多用英文输入,上线后中文用户一多就集中爆发。

5.5 导出 RTF 后中文乱码、图片消失

现象:存成.rtf用 Word 打开,中文全变成问号或者方块;带图片的文档打开后图片位置是空白;有时文件能打开但格式全乱。

原因:中文乱码十有八九是中间经过了一次字符编码转换。TextRange.Save应该直接接收FileStream,如果为了「保险」先Save到MemoryStream,再用StreamReader读成字符串写文件,RTF 里的\uNNNN转义就被破坏掉了。图片消失则是 RTF 格式本身的限制,WPF 的 RTF 序列化对InlineUIContainer里的图片支持很弱,很多情况下直接跳过。

解决:中文问题,保证Save的Stream是从文件直接打开的,中间不做任何字符串转换;图片问题,改存DataFormats.XamlPackage,它是 zip 包,图片作为资源打进去,加载时能完整还原。必须交付 RTF 的话,就把图片单独导出成文件,正文里留占位符,接收方自己插图——听起来笨,但比导出一个「图片全丢」的 RTF 更可靠。

// 正确:流直接来自文件,不经过任何编码转换 using (var stream = File.Create(path)) { range.Save(stream, DataFormats.Rtf); } // 错误示范:中间转了一次字符串,中文必乱 using var memory = new MemoryStream(); range.Save(memory, DataFormats.Rtf); var content = Encoding.UTF8.GetString(memory.ToArray()); // 这里就毁了 File.WriteAllText(path, content);

6. 二次开发:把 demo 改成能用的编辑器

拿到这份代码之后,最值得先做的一件事是把编辑区彻底组件化。demo 里RichEditor已经把RichTextBox包了一层,但对外暴露的还是控件本身,宿主窗口可以直接摸到Editor.Document。我一般会再收一层,对外只留Document依赖属性、LoadAsync、SaveAsync和几个格式操作方法,内部RichTextBox标成internal。这样将来加第二个编辑区(比如批注面板、对比视图)时,工具栏的CommandTarget只要换绑定源,逻辑一行不用改。

第二个改造点是样式预设。现在工具栏是「点一下改一下」的即时模式,用户想统一全文格式得全选再点。加一套ResourceDictionary里的段落样式(标题一、标题二、正文、引用),用Style加BasedOn定义字号、行距、段前段后距,工具栏改成下拉选择,选中后对光标所在段落paragraph.Style = ...。FlowDocument对Style的支持是完整的,这套改造成本很低,但对「像 Word」这件事的提升立竿见影。

第三个是查找替换。FlowDocument没有内置查找,得自己遍历Run,用TextRange定位再高亮。核心思路是从Document.ContentStart开始,用GetTextInRun逐个取文本块,命中后new TextRange(start, end).ApplyPropertyValue(TextElement.BackgroundProperty, highlightBrush)。要注意跨Run的匹配(比如「富文本」三个字被加粗拆成两段),稳妥的做法是先拼全文再算偏移,然后用GetPositionAtOffset反查,这条路径慢但准,几千字的文档完全够用。

最容易被低估的改造点是文档大纲。FlowDocument没有「标题」概念,得靠Paragraph.Style或者自定义附加属性标记段落级别,然后遍历一遍生成树。这一步做完,左侧挂一个TreeView,点击节点滚到对应段落(paragraph.BringIntoView()),整个编辑器的体感就从「能打字的框」变成了「能写文章的软件」。

验证这块我给个实用建议:编辑器这类控件不要只靠手点。可以在测试项目里起一个 STA 线程,创建RichTextBox,加载一段构造好的 RTF,跑一遍格式应用,再断言GetPropertyValue的返回值。WPF 控件必须在 STA 线程上创建,测试框架默认是 MTA,忘了加[STAThread]或者线程 ApartmentState 设置,测试会直接抛InvalidOperationException,报错信息还不明显。

// 测试里创建 WPF 控件的标准姿势 [Fact] public void ApplyBold_ShouldChangeFontWeight() { Exception? captured = null; var thread = new Thread(() => { try { var editor = new RichTextBox(); editor.Document.Blocks.Add(new Paragraph(new Run("测试文本"))); // 全选后应用加粗,再读回验证 editor.Selection.Select(editor.Document.ContentStart, editor.Document.ContentEnd); editor.Selection.ApplyPropertyValue(TextElement.FontWeightProperty, FontWeights.Bold); var weight = editor.Selection.GetPropertyValue(TextElement.FontWeightProperty); Assert.Equal(FontWeights.Bold, weight); } catch (Exception ex) { captured = ex; } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); if (captured is not null) throw captured; }

最后说个心态上的事。这个 demo 最容易让人上头的地方,是它让你觉得「再补几个功能就能上线」——表格、分页、页眉页脚、PDF 导出,每一项看起来都不难。我从那以后每次接编辑器需求,都先把「文档模型」和「视图」的边界画一遍再动手:哪些数据必须进FlowDocument,哪些只存在于宿主,序列化时怎么保证两边对得上。边界画不清楚,功能补得越多,后面重构越疼。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进