ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

WPF仿Word富文本编辑器:FlowDocument架构与核心实现

WPF仿Word富文本编辑器:FlowDocument架构与核心实现

简介:面向WPF桌面应用开发者的仿Word富文本编辑器开源示例,适合希望在自己项目中实现格式化文本编辑、图文混排与表格处理的中级开发者参考。项目拆分为富文本编辑核心组件与演示主程序,并提供完整解决方案,功能覆盖基础文本编辑、查找替换、HTML源码查看、打印、导出纯文本、插入图片与表格,便于理解编辑器界面与业务逻辑的分层组织。rar压缩包共289个文件,约753KB,包含76个cs源码、若干xaml与baml界面资源、gif/png图片素材、dll与pdb程序集及配置文档等,其中cs源码承载编辑核心逻辑,xaml与baml对应界面布局,体量轻便却保留较完整的工程结构。已有2875人学习下载,可据此研究WPF图形模型下的复杂编辑操作、文本与图像数据处理及.NET组件交互,并在现有代码上扩展格式化选项或文件格式支持。

1. 从「能输入」到「能交付」:一个仿 Word 的 WPF 富文本编辑器要跨过几道坎

很多人对 WPF 富文本编辑器的第一印象,是把 RichTextBox 拖进窗口、绑上按钮就完事。真做过一个仿 Word 的完整 demo 才知道,「能输入」和「能交付」之间隔着文档模型、命令栈、分页排版和一整层 Ribbon 界面。开源代码 demo 的意义就在这:它把一条已经跑通的路先铺好,让你看清每个模块该放在哪、边界划在哪,而不是从零开始一边试错一边猜。这套方案适合两类读者——毕设或课设要做桌面文档编辑器的学生,以及需要在产品里嵌一个轻量排版控件、又不想引入重型商业组件的开发者。

2. 总体架构设计:以 FlowDocument 为唯一事实来源的六层结构

架构这件事,我一般不会一上来就画框。先想清楚「谁是唯一事实来源」,后面的分层才有意义。在这个编辑器里,答案是 FlowDocument:光标、选区、格式、表格、图片全部挂在它上面,界面上任何一次点击最终都要落到这棵树上。定下这条,剩下的事情就变成「谁能改它、怎么改、改完怎么存」。

2.1 三条技术路线对比:原生 RichTextBox、自绘排版、第三方控件

WPF 做富文本,绕不开三条路。

第一条是原生路线,RichTextBox 套 FlowDocument。好处是编辑器内核、光标移动、拼写检查、无障碍支持全部白送,你只需要补分页、补导出、补界面。坏处也明显:FlowDocument 是流式模型,天生不擅长固定页排版,导出 docx 需要自己写映射,上万段的长文档会明显掉帧。

第二条是自绘路线,RichTextBox 只当输入法宿主,文档模型、排版、命中测试全部自己写。这条路能做出完全可控的分页和打印,但对团队要求很高:断行算法、双向文本、表格自动布局,每一项都是几周的活。课程设计周期里基本走不完。

第三条是引入商业控件。功能齐全,但源码不可见,遇到「导出 docx 后表格边框丢一半」这种问题只能等厂商排期,和「开源代码 demo」的定位也不搭。

三条路的对比大致是这样:

维度原生 RichTextBox自绘排版引擎商业控件
起步成本低,一天能出原型高,两周起步中,接入 + 授权
固定页分页需自己封装 Paginator完全可控通常内置
docx 保真度中等,需写映射取决于实现深度高
大文档性能万段级开始吃力可做到很好视产品而定
可定制性高,源码在手最高低

这个 demo 走的是第一条路,把 FlowDocument 当成唯一事实来源,所有扩展围绕它做。选型的判断依据很朴素:交付周期内,能不能把「表格 + 图片 + 分页 + 导出」四件事同时做扎实。原生的底子已经解决了其中一半,剩下的一半是可以靠封装补上的;自绘则是把已经解决的那一半推倒重来。

2.2 六层的职责划分与依赖方向

分层的关键不是层数多,而是依赖方向单一。这个编辑器分成六层,上层依赖下层,反向一律通过接口或事件回调:

层职责关键类型
文档层承载唯一可信的文档树FlowDocument / Paragraph / Table
编辑服务层所有修改以命令形式进入IEditorCommand / UndoStack
视图层渲染、光标、选区、分页RichTextBox / DocumentPaginator
界面层Ribbon、状态栏、对话框RibbonTab / RoutedUICommand
持久化层打开与保存各类格式DocumentIO / OpenXmlMapper
扩展层表格、图片、查找替换、字数统计IEditorPlugin

一个容易翻车的地方是编辑服务层和界面层的边界。如果 Ribbon 上的「加粗」按钮直接去改TextElement.FontWeightProperty,撤销栈就永远记不到这一步,用户按 Ctrl+Z 会发现格式只回退一半。约定是:界面层只发命令,不碰文档。Ribbon 按钮把RoutedCommand抛出去,由编辑服务层接收、执行、入栈,视图层只负责把执行结果画出来。这条约定写在代码注释里,比写在设计文档里有用得多。

扩展层用插件接口收口,是为了让「查找替换」「字数统计」这类功能不往主窗口里堆。每个插件实现三个方法:初始化时挂菜单、文档变更时刷新状态、卸载时清理事件订阅。少了第三条,窗口关不掉、内存不释放这类问题会一直留着。

public interface IEditorPlugin { string Name { get; } // host 暴露当前文档、命令总线和状态栏,插件只能通过这些入口操作 void Attach(IEditorHost host); void Detach(); } public interface IEditorHost { FlowDocument Document { get; } void Execute(IEditorCommand command); // 唯一允许修改文档的入口 void SetStatus(string text); // 状态栏提示,避免插件直接抓控件 }

Attach里挂事件、Detach里退订,是这个接口能成立的前提。我一般会要求插件在Detach之后不再持有任何TextPointer——TextPointer 会拖住整棵文档树,一个泄漏就能让几十兆的内存常驻。

2.3 文档模型拆解:FlowDocument 的块级与行内结构

FlowDocument 是一棵树,块级元素(Block)和行内元素(Inline)两套体系。块级负责段落级结构:Paragraph、Table、List、Section、BlockUIContainer;行内负责字符级结构:Run、Span、Bold、Italic、Hyperlink、InlineUIContainer、LineBreak。

<FlowDocument xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <Paragraph FontSize="16" FontWeight="Bold">文档标题</Paragraph> <Paragraph> 正文第一段,其中 <Bold>加粗片段</Bold> 与 <Span Foreground="Gray">灰色说明</Span> 可以混排。 </Paragraph> <List MarkerStyle="Decimal"> <ListItem><Paragraph>列表项一</Paragraph></ListItem> <ListItem><Paragraph>列表项二</Paragraph></ListItem> </List> <Table CellSpacing="0"> <TableRowGroup> <TableRow><TableCell><Paragraph>表头</Paragraph></TableCell></TableRow> </TableRowGroup> </Table> </FlowDocument>

理解这棵树的关键是两点。第一,格式是属性,不是节点。Bold之所以存在,只是Span上FontWeight的一个语法糖;你写run.FontWeight = FontWeights.Bold和用<Bold>包裹,最终树结构是一样的。第二,属性有继承。段落上的FontSize会被下面的Run继承,除非Run自己设了本地值。这解释了一个高频疑问:为什么代码里读到的字号和用户设的不一样——GetValue拿到的是本地值,TextRange.GetPropertyValue拿到的才是继承后的有效值。

遍历时我一般用LogicalTreeHelper.GetChildren递归,而不是遍历Blocks集合本身,因为Section和Table会嵌出多层,只看一层会漏掉嵌套内容。遍历结果通常用来做字数统计、全文替换和目录生成,这三件事的共同点是都需要对整棵树做一次线性扫描。

3. 核心编辑能力实现:序列化、光标、撤销栈与表格图片

架构定完,落地就是四件事:文档怎么存、光标怎么定位、改错了怎么退、复杂元素怎么插。这四件事做不扎实,界面做得再像 Word 也只是个空壳。

3.1 打开与保存:XamlPackage、RTF 与 docx 的取舍

WPF 提供了TextRange.Save/Load这个便捷入口,但格式选择上有坑。DataFormats.Xaml只保存 XAML 文本,图片以 URI 引用的形式存在,文件一挪位置就变红叉;DataFormats.XamlPackage是 ZIP 容器,把图片等资源一起打包进去,这才是本地存档该用的格式。

public enum DocFormat { XamlPackage, Rtf, Text } public static class DocumentIO { public static void Save(FlowDocument doc, string path, DocFormat format) { using FileStream fs = File.Create(path); TextRange range = new TextRange(doc.ContentStart, doc.ContentEnd); DataFormat fmt = format switch { DocFormat.XamlPackage => DataFormats.XamlPackage, // 图片随包走,推荐存档 DocFormat.Rtf => DataFormats.Rtf, // 便于和外部工具交换 DocFormat.Text => DataFormats.Text, // 只留纯文本,用于预览 _ => throw new ArgumentOutOfRangeException(nameof(format)) }; range.Save(fs, fmt); } public static FlowDocument Load(string path, DocFormat format) { FlowDocument doc = new FlowDocument(); using FileStream fs = File.OpenRead(path); TextRange range = new TextRange(doc.ContentStart, doc.ContentEnd); DataFormat fmt = format == DocFormat.XamlPackage ? DataFormats.XamlPackage : DataFormats.Rtf; range.Load(fs, fmt); doc.UndoCapacity = 100; // 默认 100 步够用,长文档可调到 200 return doc; } }

TextRange的两个参数是起点和终点,取doc.ContentStart到doc.ContentEnd表示整篇。UndoCapacity是 RichTextBox 自带撤销栈的深度,默认值对短文档够用,对动辄几十页的文档偏小,编辑到中段就开始「按到某一步退不回去了」,调大一些能缓解。

docx 则没有内建通道。可行的做法是引入 OpenXML SDK,写一层OpenXmlMapper,把WordprocessingDocument里的Paragraph、Table、Run逐节点映射成 FlowDocument 的对应节点,反向再写一遍导出。映射过程中最耗时的不是文本,是样式:字号要除以 2(半磅转磅)、颜色要从 hex 转SolidColorBrush、表格边框要按边的方向分别取BorderThickness。demo 里通常只覆盖常用子集——标题、正文、加粗斜体、表格、图片——把「打开不崩、导出能看」作为及格线,剩下的慢慢补。

3.2 光标与选区:TextPointer 的定位与属性读写

TextPointer 是这套体系里最容易被低估的类型。它不是索引,是「两个字符之间的位置」,带一个LogicalDirection表示朝前还是朝后。这一句话没理解透,后面全是玄学。

// 取光标所在段落并读取其有效对齐方式 TextPointer caret = editor.CaretPosition; Paragraph? para = caret.Paragraph; // 光标不在段落里时为 null if (para is not null) { // GetValue 拿本地值,TextRange.GetPropertyValue 拿继承后的有效值 var range = new TextRange(para.ContentStart, para.ContentEnd); object effective = range.GetPropertyValue(Paragraph.TextAlignmentProperty); TextAlignment align = (TextAlignment)effective; } // 选区两端可能方向相反,取 Start/End 更稳妥 TextSelection sel = editor.Selection; if (!sel.IsEmpty) { TextPointer from = sel.Start; // 已按文档顺序归一化 TextPointer to = sel.End; int length = from.GetOffsetToPosition(to); }

三个参数值得单独说。caret.Paragraph是向上找最近的Paragraph祖先,光标落在表格单元格或图片容器里时返回的是所在段落,不是 null,这点和直觉一致。GetOffsetToPosition返回的是符号偏移量,一个汉字算一个符号,但一个\r\n在 FlowDocument 里算两个,所以字数统计不能直接拿它当年字数用,需要遍历Run累加Text.Length。sel.Start/End已经按文档顺序归一化,而AnchorPosition/FocusPosition保留用户拖选方向,做格式刷时前者够用,做选区扩展时要用后者。

还有一个小细节:TextPointer会随文档变更失效。保存快照式的选区(比如「记住上次编辑位置」)必须在每次文档变更后重新解析一遍,否则下次跳转就跳到别处去了。

3.3 撤销重做:命令模式怎么和 RichTextBox 自带栈共存

RichTextBox 本身有撤销,为什么还要自己做一套?因为它只认自己视图内部的操作。你在 Ribbon 上插入一个表格、批量替换了二十处文字、用代码改了一批段落的行距,这些都不在它的栈里。用户按 Ctrl+Z,格式退回去了,表格还在——这就是典型的撤销断层。

public interface IEditorCommand { string Name { get; } void Execute(); void Undo(); } public sealed class UndoStack { private readonly Stack<IEditorCommand> _undo = new(); private readonly Stack<IEditorCommand> _redo = new(); private const int MaxDepth = 100; public void Push(IEditorCommand cmd) { cmd.Execute(); _undo.Push(cmd); _redo.Clear(); // 新操作让重做链失效,这一步最常被漏掉 if (_undo.Count > MaxDepth) TrimOldest(); } public void Undo() { if (_undo.Count == 0) return; IEditorCommand cmd = _undo.Pop(); cmd.Undo(); _redo.Push(cmd); } public void Redo() { if (_redo.Count == 0) return; IEditorCommand cmd = _redo.Pop(); cmd.Execute(); _undo.Push(cmd); } private void TrimOldest() { /* 栈底淘汰,保持深度上限 */ } }

Execute和Undo必须严格互逆,这是命令模式能用的唯一前提。实现上有个务实的选择:与其为每个操作写精细的逆操作,不如在命令里存一份「操作前的选区文本 + 格式」快照,撤销时用快照覆盖回去。快照的代价是内存,一个 300 字的段落快照不到 1 KB,100 步也就几百 KB,换来的是不会出现「撤销到一半文档结构错乱」这种噩梦。

另一件必须做的是合并。用户连点十次加粗,不应该产生十条命令。常见做法是给命令加一个合并键(操作类型 + 目标范围),新命令入栈前和栈顶比对,键相同就替换而不是追加,再把一个 500ms 的时间窗作为额外条件,超时就不合并。

3.4 表格与图片:BlockUIContainer 与 Table 的插入逻辑

表格是仿 Word 的必备项,也是最容易做出「看起来能行、用起来全是洞」的模块。

public static Table BuildTable(int rows, int cols) { Table table = new Table { CellSpacing = 0, Margin = new Thickness(0, 8, 0, 8) }; for (int c = 0; c < cols; c++) table.Columns.Add(new TableColumn()); // 列宽留空,交给自动布局 TableRowGroup group = new TableRowGroup(); table.RowGroups.Add(group); for (int r = 0; r < rows; r++) { TableRow row = new TableRow(); for (int c = 0; c < cols; c++) { TableCell cell = new TableCell(new Paragraph(new Run(string.Empty))) { BorderBrush = Brushes.Gray, BorderThickness = new Thickness(0.5), Padding = new Thickness(4) }; row.Cells.Add(cell); } if (r == 0) row.Background = Brushes.LightGray; // 首行当表头 group.Rows.Add(row); } return table; }

Columns里加空的TableColumn是必须的,数量决定列数,宽度留空才会自动均分;只往Cells里塞内容而不加TableColumn,渲染出来的列宽会塌成一条。CellSpacing = 0关掉单元格间隙,否则边框是双线,和 Word 默认样式差很远。表头底色放在TableRow.Background上而不是TableCell上,导出时才能被识别成表头行。

图片分两种。整段独占的图走BlockUIContainer,行内小图标走InlineUIContainer:

BitmapImage bmp = new BitmapImage(); bmp.BeginInit(); bmp.UriSource = new Uri(imagePath, UriKind.Absolute); bmp.CacheOption = BitmapCacheOption.OnLoad; // 立即读进内存,不长期占用文件句柄 bmp.EndInit(); bmp.Freeze(); // 冻结后才能跨线程使用、参与打印 Image img = new Image { Source = bmp, Stretch = Stretch.Uniform, MaxWidth = 480 }; document.Blocks.Add(new BlockUIContainer(img));

CacheOption.OnLoad和Freeze()这两行不是可选项。不设OnLoad,用户删掉原图文件后文档里的图就没了;不Freeze,打印或导出到后台线程时直接抛异常。MaxWidth限制在正文宽度以内,否则一张 4000 像素的照片会把整页撑爆。

4. 仿 Word 界面层实现:Ribbon、分页视图与格式刷

界面层的目标不是好看,是「用户不用学」。Word 用户对功能区的位置有肌肉记忆,字号在最左边、加粗在旁边、对齐方式在下一组,照着排就对了。

4.1 Ribbon 工具栏与 RoutedCommand 的绑定方式

WPF 自带EditingCommands,加粗、斜体、下划线、对齐、缩进全都有现成的RoutedCommand,前提是把CommandTarget指向编辑器。

<Ribbon x:Name="MainRibbon" AutomationProperties.Name="主功能区"> <RibbonTab Header="开始"> <RibbonGroup Header="字体"> <RibbonComboBox x:Name="FontFamilyBox" IsEditable="True" Width="140" SelectionChanged="OnFontFamilyChanged"/> <RibbonComboBox x:Name="FontSizeBox" IsEditable="True" Width="70"/> <RibbonButton Label="加粗" SmallImageSource="/Icons/bold.png" Command="EditingCommands.ToggleBold" CommandTarget="{Binding ElementName=Editor}" Focusable="False"/> <RibbonButton Label="倾斜" SmallImageSource="/Icons/italic.png" Command="EditingCommands.ToggleItalic" CommandTarget="{Binding ElementName=Editor}" Focusable="False"/> </RibbonGroup> <RibbonGroup Header="段落"> <RibbonToggleButton Label="左对齐" Command="EditingCommands.AlignLeft" CommandTarget="{Binding ElementName=Editor}" Focusable="False"/> <RibbonToggleButton Label="居中" Command="EditingCommands.AlignCenter" CommandTarget="{Binding ElementName=Editor}" Focusable="False"/> </RibbonGroup> </RibbonTab> </Ribbon>

CommandTarget是这套绑定的命门。EditingCommands是路由命令,不指定目标就会沿着可视树往上冒泡,冒到 Ribbon 就断了,按钮永远是灰的。Focusable="False"同样关键——按钮一旦能拿焦点,点下去编辑器就失焦,选区消失,加粗作用在一个空选区上,用户看到的现象就是「点了没反应」。

CanExecute也要接管。表格里选中整行时,「项目符号」应该禁用;光标在只读区域时,所有编辑命令都应该变灰。做法是在CommandBinding的CanExecute里读一次当前上下文,把判断集中在一个方法里,别散落到十几个事件处理器里。

4.2 分页视图与标尺:把流式文档切成 A4 页

FlowDocument 是流式的,天生没有「页」的概念。要做出 Word 那样的分页效果,走DocumentPaginator:

public static DocumentPaginator CreateAPaginator(FlowDocument doc) { // 96 DPI 下,A4 = 210mm x 297mm ≈ 794 x 1123 设备无关像素 doc.PageWidth = 794; doc.PageHeight = 1123; doc.PagePadding = new Thickness(72); // 1 英寸页边距,对齐 Word 默认值 doc.ColumnWidth = double.PositiveInfinity; // 单栏,防止自动分栏 return ((IDocumentPaginatorSource)doc).DocumentPaginator; }

三个参数的含义要说清。PageWidth/PageHeight用的是设备无关像素,换算基准是 96 DPI,所以 A4 是 794×1123 而不是 210×297。PagePadding是内容区到纸边的距离,设成 72 就对应 1 英寸,和 Word 中文档默认页边距一致,这样导出后两边对得上。ColumnWidth设成PositiveInfinity是为了强制单栏——FlowDocument 在页面够宽时会自动分栏,屏幕上看着挺整齐,打印出来就变成报纸排版。

标尺的实现思路是反过来的:不画纸,画内容区。横向标尺对应PagePadding.Left到PageWidth - PagePadding.Right这一段,刻度按厘米换算,1 厘米约等于 37.8 设备无关像素。缩进标记用Paragraph.Margin和TextIndent驱动,拖动时直接改属性,改完再让DocumentPaginator重新分页。

分页视图本身有两种做法:FlowDocumentPageViewer省事但不可控,DocumentPageView配合自定义的翻页容器灵活但要多写几十行。demo 里通常选后者,因为要处理「页面缩放」「双页并排」「页间阴影」这些 Word 用户期待的效果。

4.3 格式刷与样式联动:属性快照的正确抓法

格式刷看着简单,实现上有个容易忽略的点:抓的是有效值,不是本地值。

private static readonly DependencyProperty[] CharProps = { TextElement.FontFamilyProperty, TextElement.FontSizeProperty, TextElement.FontWeightProperty, TextElement.FontStyleProperty, TextElement.ForegroundProperty, TextElement.BackgroundProperty }; private Dictionary<DependencyProperty, object>? _snapshot; public void CaptureFormat(TextRange source) { // GetPropertyValue 返回继承后的有效值,比 GetValue 更贴近用户看到的样式 _snapshot = CharProps.ToDictionary(p => p, p => source.GetPropertyValue(p)); } public void ApplyFormat(TextRange target) { if (_snapshot is null) return; foreach (KeyValuePair<DependencyProperty, object> kv in _snapshot) target.ApplyPropertyValue(kv.Key, kv.Value); }

GetPropertyValue和GetValue的差别就在继承上。用户在段落上设了 14 号字,光标选中的Run本身没有本地字号,GetValue返回NaN,GetPropertyValue返回 14。格式刷要复制的是「看起来的样子」,所以必须用后者。

ApplyPropertyValue也有代价:它会给选区内每个元素打上本地值,选区一长,元素的属性表就膨胀,撤销栈也跟着涨。所以格式刷只作用在非空选区上,且对超过一定长度的选区给出提示,别让用户一把刷完整篇。

样式联动是另一层。标题 1、标题 2、正文这些样式应该存成Style资源,Ribbon 上的下拉框切换的是Paragraph.Style,而不是逐个属性去改。这样改一次样式定义,全篇同类段落一起变,才是 Word 的用法。

5. 避坑与排查:五类上线后才暴露的问题

自测阶段一切正常、交付后问题接踵而来,是这类项目最常见的剧本。下面五条是踩过之后印象最深的,按「现象 → 原因 → 解决」写清楚,遇到同类问题可以直接对照。

5.1 渲染与持久化类:图片丢失、打印走样、粘贴卡顿

保存后图片全变红叉。现象是文档里图片显示正常,关掉重开后全变成破图占位符。原因多半是用了DataFormats.Xaml保存——这个格式存的是 XAML 文本,图片只留一个指向临时目录的 URI,临时目录一清理就失效。解决是改用DataFormats.XamlPackage,它是 ZIP 容器,会把图片字节一起打进去;如果出于兼容性必须用纯 XAML,就得自己维护一个资源字典,把图片 base64 内嵌进 XAML 并用BitmapImage.StreamSource还原。

打印出来的版式和屏幕上对不上,行尾被切掉半行。现象是预览时好好的,打出来右边少一截,或者页脚文字被截断。原因通常是屏幕排版按 96 DPI 算、打印机按物理尺寸算,两套坐标系没对齐,再叠加PagePadding和PrintTicket页边距双重扣减。解决是打印前用独立的DocumentPaginator重新分页,把PageWidth/PageHeight设成纸张实际尺寸、PagePadding设成 0,页边距只在PrintTicket里设一次;屏幕预览和打印走同一段分页代码,别各写一套。

从 Word 粘贴一段带格式的文字,界面卡五秒以上。现象是 Ctrl+V 之后整个窗口假死,任务管理器里 CPU 拉满。原因是剪贴板里带过来的是完整的 HTML 或 RTF,段落数量多、内联样式多、还夹着内嵌图片,逐段ApplyPropertyValue会触发成百上千次布局重算。解决是先把剪贴板内容Load到一个离屏的临时 FlowDocument,在离屏文档里做清洗(去掉不认识的样式、压缩连续空格、把图片转成BlockUIContainer),清洗完再一次性合并进主文档;内容超过一定规模时用Dispatcher分批插入并给出进度。

5.2 交互与状态类:撤销断层、点工具栏选区消失

按 Ctrl+Z 只能退一半,格式回去了但表格还在。现象是用户插了一个表格又改了字号,撤销一次字号变回去,再按一次什么也不发生。原因是表格插入走的是自定义代码路径,没进UndoStack,只进了 RichTextBox 自带的栈;而字号修改走的又是另一条路径。解决是把「所有修改文档的操作」统一收口到IEditorHost.Execute,界面层不允许直接碰FlowDocument,RichTextBox 自带的IsUndoEnabled关掉,撤销入口只保留一个。收口之后,新加功能只要忘了走命令总线,撤销就会明显不对,反而容易发现。

点了 Ribbon 上的加粗,选区没了,字也没变粗。现象是选中一段文字,鼠标移到工具栏点一下,选区高亮消失,操作无效。原因是按钮抢走了键盘焦点,RichTextBox 失焦后选区被清空,命令作用在一个空位置上。解决是给所有工具栏按钮加Focusable="False",同时在PreviewMouseDown里保存一次Selection.Start/End快照,命令执行前用快照恢复选区。这两步做完,还要检查RibbonComboBox这类可编辑控件——它必须能拿焦点,那就得在DropDownOpened时存快照、DropDownClosed时恢复。

6. 验收与答辩:一份 20 分钟能跑完的检查表

到了答辩或交付环节,最怕的不是功能少,是演示到一半翻车。我在验收时固定跑一张表,按顺序走一遍,任何一项不过就先修再演示:

检查项操作判定标准
文档打开打开一份含标题、列表、表格、图片的存档无异常弹窗,图片正常显示
格式往返加粗、斜体、字号、颜色各设一次后保存再打开四项格式完全保留
表格编辑插入 5×4 表格,跨单元格选中后合并结构正确,边框不断线
撤销深度连续做 20 次不同操作,按 20 次 Ctrl+Z回到初始状态,无残影
分页一致性切到分页视图,翻到最后一页页数与状态栏显示一致
打印预览打开打印预览行尾不截断,页边距与屏幕一致
粘贴压力从外部粘贴一份 3000 字带格式文本3 秒内完成,界面不假死
长文档连续插入 200 个段落滚动流畅,输入无明显延迟

答辩时的讲述顺序也有讲究。别从技术栈开始讲,从「一个具体的使用场景」切进去:打开一份带表格的文档、改两处格式、撤销、导出、打印预览,五个动作两分钟走完,评委立刻知道这东西能干什么。然后再回头讲分层、讲为什么选 FlowDocument、讲撤销栈为什么要自己维护——这时候技术细节才有落点。被问到「你这个和 Word 差在哪」,诚实回答:差在协同编辑、差在复杂域、差在排版引擎的精度,但轻量嵌入、源码可控、二次开发成本低这三条是它的位置。

有个血泪教训值得说:早些年我做类似的项目,验收前一周才发现「保存后重新打开,表格边框丢了一半」,追下去是导出映射里只处理了外边框没处理内边框。从那以后我每次动持久化层,都强制走一遍「改格式 → 保存 → 关程序 → 重开 → 逐项比对」这条链路,哪怕只改了一行代码。这份 demo 的工程结构和上面的分层是一一对应的,把它当参照物对照着自己的实现走一遍,能省下不少返工。希望帮到你。

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

RELATED READING

延伸阅读

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