简介:这是一份基于C# WinForm平台开发的富文本编辑器实战项目,面向C#初学者与WinForm进阶开发者,帮助快速掌握RichTextBox控件的核心功能封装与UI交互设计。资源完整实现了加粗、斜体、下划线、字体颜色/背景色设置、多级对齐(左/中/右)、段落缩进与反缩进、项目符号与编号列表、图片插入、内容查找及打印等典型编辑功能,代码结构清晰,含15个.cs源文件(如MainForm、RichFormatFactory、BaseRichFormat等)支撑功能分层,6个.gif操作动图直观展示界面效果,3个.exe可执行文件便于即刻运行验证,辅以.sln/.csproj工程文件和.resx资源文件保障开箱即用。压缩包共66个文件,总大小174KB,轻量紧凑且模块划分明确。目前已有642人学习下载,读者可直接获取可运行的完整工程、规范的格式工厂设计模式实践、丰富的UI图标资源及配套配置文件,是理解WinForm富文本处理机制的优质参考范例。
1. 为什么一个“WinForm + RichTextBox”的文本编辑器,至今仍是产线工具开发的首选落地形态?
你可能在 GitHub 上扫过几十个“C# 文本编辑器”项目,点开一看:WPF 渲染、MVVM 绑定、插件系统、语法高亮引擎……但真要给工厂 MES 系统配个日志查看器,给设备调试软件加个配置脚本编辑区,或者给内部 QA 工具嵌一个可保存的测试用例输入框——最后上线的,八成还是 WinForm + RichTextBox 搭出来的那个灰扑扑、没动画、右键菜单只有“复制/粘贴/全选”的小窗体。这不是技术倒退,而是 WinForm 的 RichTextBox 在低耦合、零依赖、强可控、易维护四个维度上,至今没被任何上层框架真正替代。它不依赖 .NET Core 运行时,不卡在 DPI 缩放黑盒里,不因 WPF 渲染线程挂掉而整个窗体失响应,更不会因为某个 NuGet 包版本冲突就编译不过。本文不讲“如何用 RichTextBox 显示一段文字”,而是聚焦真实产线场景:如何让这个控件撑起一个能保存 UTF-8 带 BOM 的配置文件、支持 Ctrl+Z/Y 多级撤销、保留原始缩进与换行符、右键菜单按上下文动态启用、状态栏实时显示光标行列号与编码格式的轻量级文本编辑器。适合正在用 VS2015/VS2019 开发工业软件、设备配套工具、内部运维平台的 C# 工程师——你不需要重构整个 UI 层,只要把这一块“文本编辑能力”稳稳焊死在现有 WinForm 窗体里。
2. 从空窗体到可编辑:RichTextBox 初始化与基础行为接管
RichTextBox 不是拿来即用的“编辑器”,它默认行为对生产环境而言太“野”:Ctrl+A 会选中整个控件(包括不可见的滚动条区域)、Enter 键插入的是\r\n而非\n、撤销栈在窗口失去焦点后自动清空、字体缩放会破坏行高一致性。我们必须在初始化阶段就“驯服”它,而不是等用户反馈“为什么我按 Ctrl+Z 没反应”。
2.1 创建最小可运行窗体并禁用默认干扰行为
新建 WinForm 项目(VS2015 或更高),拖入 RichTextBox 控件,命名为rtbEditor。关键不是把它放上去,而是立刻在Form_Load中执行以下初始化:
private void Form1_Load(object sender, EventArgs e) { // 1. 强制使用固定宽度字体,避免中文/英文混排错位(产线日志常见问题) rtbEditor.Font = new Font("Consolas", 10f, FontStyle.Regular); // 2. 关闭自动换行,保证日志/配置文件原始格式不被破坏 rtbEditor.WordWrap = false; // 3. 禁用 RichTextBox 自带的右键菜单,我们自己实现(否则无法控制“粘贴”是否触发格式清理) rtbEditor.ContextMenuStrip = null; rtbEditor.ShortcutsEnabled = false; // 关键!禁用内置快捷键,否则 Ctrl+Z/Y 会被劫持 // 4. 启用多级撤销(默认只有一级,必须显式开启) rtbEditor.EnableAutoDragDrop = false; // 防止拖放文本污染撤销栈 rtbEditor.UndoLimit = 100; // 设为 100 级,足够覆盖常规编辑深度 // 5. 设置默认文本模式为纯文本(绕过 RTF 解析开销,提升大文件加载速度) rtbEditor.Text = string.Empty; }提示:
ShortcutsEnabled = false是血泪经验。很多团队卡在“Ctrl+Z 不生效”,查半天发现是 RichTextBox 内置快捷键和自定义命令冲突。关掉它,所有快捷键由我们统一捕获处理,控制权才真正回来。
2.2 手动接管 Ctrl+Z / Ctrl+Y 撤销重做逻辑
RichTextBox 的Undo()和Redo()方法必须在用户操作后显式调用,且需配合CanUndo/CanRedo状态判断。我们用KeyDown事件做拦截:
private void rtbEditor_KeyDown(object sender, KeyEventArgs e) { // 拦截 Ctrl+Z(撤销) if (e.Control && e.KeyCode == Keys.Z) { e.SuppressKeyPress = true; // 阻止系统默认行为 if (rtbEditor.CanUndo) { rtbEditor.Undo(); UpdateStatusBar(); // 后续章节实现 } } // 拦截 Ctrl+Y(重做) else if (e.Control && e.KeyCode == Keys.Y) { e.SuppressKeyPress = true; if (rtbEditor.CanRedo) { rtbEditor.Redo(); UpdateStatusBar(); } } // 拦截 Ctrl+S(保存)——此处仅占位,实际保存逻辑见第 4 章 else if (e.Control && e.KeyCode == Keys.S) { e.SuppressKeyPress = true; SaveCurrentFile(); } }这段代码的逻辑重点在于:SuppressKeyPress = true必须设置,否则 Windows 会同时触发 RichTextBox 内置 Undo 和你的Undo()调用,导致撤销栈错乱。实测中,未加此行会导致连续按两次 Ctrl+Z 后,第三次按失效——因为撤销栈被重复消费了。
2.3 行列号与编码状态实时反馈:状态栏驱动的光标监听
产线人员看日志最怕“第 127 行出错”,却找不到光标在哪。我们需要在状态栏(StatusStrip+ToolStripStatusLabel)中实时显示当前光标位置与文件编码。关键不是获取位置,而是在每次光标移动、内容变更后精准触发更新:
// 在窗体设计器中添加 StatusStrip,内含两个 ToolStripStatusLabel:tslblPosition 和 tslblEncoding private void rtbEditor_SelectionChanged(object sender, EventArgs e) { // 获取光标所在行号(基于 \n 计数,兼容 \r\n 和 \n) int line = rtbEditor.GetLineFromCharIndex(rtbEditor.SelectionStart) + 1; int col = rtbEditor.SelectionStart - rtbEditor.GetFirstCharIndexFromLine(line - 1) + 1; tslblPosition.Text = $"Ln {line}, Col {col}"; // 更新编码显示(实际编码需在加载/保存时确定,此处设为占位) tslblEncoding.Text = "UTF-8"; } private void rtbEditor_TextChanged(object sender, EventArgs e) { // 内容变更时也刷新位置(如粘贴后光标跳到末尾) rtbEditor_SelectionChanged(sender, e); }注意:GetLineFromCharIndex返回的是基于\n的行号,对 Windows 风格的\r\n自动兼容,无需手动替换。这是 .NET Framework 对 RichTextBox 的底层优化,不必自行解析字符串。
3. 文件读写与编码控制:为什么 UTF-8 带 BOM 是产线配置文件的硬性要求?
产线设备厂商提供的配置文件,90% 是 Notepad 保存的 UTF-8 with BOM 格式。如果编辑器用Encoding.UTF8直接读取,BOM(0xEF 0xBB 0xBF)会被当作可见字符显示为,导致设备解析失败。反之,若用Encoding.Default(通常是 GBK),又会把英文日志中的©、®符号变成乱码。必须在读写两端严格控制 BOM 行为。
3.1 安全读取:自动识别 BOM 并剥离,同时记录原始编码
不能依赖文件扩展名,必须读取文件头字节判断。我们封装一个ReadTextWithEncoding方法:
public static (string content, Encoding encoding) ReadTextWithEncoding(string filePath) { if (!File.Exists(filePath)) return (string.Empty, Encoding.UTF8); byte[] buffer = File.ReadAllBytes(filePath); if (buffer.Length < 2) return (Encoding.UTF8.GetString(buffer), Encoding.UTF8); // 检查 UTF-8 BOM: EF BB BF if (buffer.Length >= 3 && buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF) { byte[] contentBytes = new byte[buffer.Length - 3]; Array.Copy(buffer, 3, contentBytes, 0, contentBytes.Length); return (Encoding.UTF8.GetString(contentBytes), new UTF8Encoding(true)); // true 表示保留 BOM } // 检查 UTF-16 BE BOM: FE FF if (buffer.Length >= 2 && buffer[0] == 0xFE && buffer[1] == 0xFF) { byte[] contentBytes = new byte[buffer.Length - 2]; Array.Copy(buffer, 2, contentBytes, 0, contentBytes.Length); return (Encoding.BigEndianUnicode.GetString(contentBytes), Encoding.BigEndianUnicode); } // 检查 UTF-16 LE BOM: FF FE if (buffer.Length >= 2 && buffer[0] == 0xFF && buffer[1] == 0xFE) { byte[] contentBytes = new byte[buffer.Length - 2]; Array.Copy(buffer, 2, contentBytes, 0, contentBytes.Length); return (Encoding.Unicode.GetString(contentBytes), Encoding.Unicode); } // 无 BOM,按 UTF-8 尝试解码(产线日志绝大多数为 UTF-8) try { return (Encoding.UTF8.GetString(buffer), Encoding.UTF8); } catch { // UTF-8 解码失败,降级为系统默认编码(GBK) return (Encoding.Default.GetString(buffer), Encoding.Default); } }注意:
new UTF8Encoding(true)中的true参数表示“生成 BOM”,这是后续保存时的关键。很多团队误用Encoding.UTF8(其Preamble为空),导致保存后设备无法识别。
3.2 可靠保存:强制写入 UTF-8 with BOM,且保留原始换行符
用户可能在 Linux 服务器上生成\n换行的日志,也可能在 Windows 上生成\r\n的配置。RichTextBox 内部统一用\r\n存储,但保存时必须还原原始风格——否则设备固件解析会失败。我们通过Lines属性获取每行文本,再手动拼接:
private bool SaveToFile(string filePath, Encoding targetEncoding) { try { // 获取原始行数组(保留 RichTextBox 内部的 \r\n) string[] lines = rtbEditor.Lines; // 按目标编码生成字节流 byte[] preamble = targetEncoding.GetPreamble(); List<byte> allBytes = new List<byte>(preamble); for (int i = 0; i < lines.Length; i++) { byte[] lineBytes = targetEncoding.GetBytes(lines[i]); allBytes.AddRange(lineBytes); // 末行不加换行符,其余行加 \r\n(Windows 风格) if (i < lines.Length - 1) { allBytes.AddRange(new byte[] { 0x0D, 0x0A }); // \r\n } } File.WriteAllBytes(filePath, allBytes.ToArray()); return true; } catch (Exception ex) { MessageBox.Show($"保存失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } } // 调用示例:保存为 UTF-8 with BOM private void SaveCurrentFile() { if (string.IsNullOrEmpty(currentFilePath)) { SaveAsDialog(); return; } SaveToFile(currentFilePath, new UTF8Encoding(true)); }这里的关键是:不用File.WriteAllText,而用File.WriteAllBytes+ 手动拼接字节。因为WriteAllText会强制使用\r\n,且无法控制 BOM 写入时机。手动拼接让我们完全掌控每个字节,满足设备固件对文件格式的苛刻要求。
4. 右键菜单与上下文感知:一个菜单项的启用逻辑比想象中复杂
产线工具的右键菜单不是“复制/粘贴/全选”三件套,而是需要根据光标位置、选中文本长度、当前是否为只读状态动态启停。例如:当光标在空白行时,“注释行”应禁用;当选中 1000 行日志时,“转为 CSV”才可用;当文件以只读方式打开时,所有编辑类菜单项必须灰显。
4.1 构建动态右键菜单:ToolStripDropDownMenu + 状态驱动
在窗体设计器中添加ContextMenuStrip,命名为cmsEditor,添加以下菜单项:
tmiUndo(撤销)tmiRedo(重做)tmiSeparator1tmiCut(剪切)tmiCopy(复制)tmiPaste(粘贴)tmiDelete(删除)tmiSeparator2tmiSelectAll(全选)tmiSeparator3tmiCommentLine(注释行)tmiUncommentLine(取消注释)
然后绑定Opening事件,在菜单弹出前计算每一项的Enabled状态:
private void cmsEditor_Opening(object sender, CancelEventArgs e) { // 所有项默认禁用,按条件逐个启用 tmiUndo.Enabled = rtbEditor.CanUndo; tmiRedo.Enabled = rtbEditor.CanRedo; bool hasSelection = rtbEditor.SelectionLength > 0; bool isReadOnly = rtbEditor.ReadOnly; tmiCut.Enabled = hasSelection && !isReadOnly; tmiCopy.Enabled = hasSelection; tmiPaste.Enabled = !isReadOnly; tmiDelete.Enabled = hasSelection && !isReadOnly; tmiSelectAll.Enabled = rtbEditor.TextLength > 0; // 注释/取消注释:仅当有选中文本,且每行首字符非 '#' 时启用 if (hasSelection) { string selectedText = rtbEditor.SelectedText; string[] lines = selectedText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); bool allLinesStartWithHash = lines.All(l => l.TrimStart().StartsWith("#")); tmiCommentLine.Enabled = !allLinesStartWithHash && !isReadOnly; tmiUncommentLine.Enabled = allLinesStartWithHash && !isReadOnly; } else { tmiCommentLine.Enabled = tmiUncommentLine.Enabled = false; } }4.2 实现“注释行”与“取消注释行”:精准定位行首,不破坏缩进
这是产线配置编辑的核心功能。不能简单地在每行开头加#,必须保留原有缩进,否则设备固件会因格式错误拒绝加载:
private void tmiCommentLine_Click(object sender, EventArgs e) { if (rtbEditor.SelectionLength == 0) return; string selectedText = rtbEditor.SelectedText; string[] lines = selectedText.Split(new[] { '\r', '\n' }, StringSplitOptions.None); // 保留空行和换行符 List<string> newLines = new List<string>(); foreach (string line in lines) { if (string.IsNullOrWhiteSpace(line)) { newLines.Add(line); continue; } // 查找行首非空白字符位置 int firstNonWs = line.IndexOfAny(new char[] { ' ', '\t' }); if (firstNonWs == -1) { // 全是空白,直接加 # newLines.Add("#" + line); } else { // 提取缩进部分,再加 # string indent = line.Substring(0, firstNonWs); string rest = line.Substring(firstNonWs); newLines.Add(indent + "#" + rest); } } string newText = string.Join("\r\n", newLines); rtbEditor.SelectedText = newText; } private void tmiUncommentLine_Click(object sender, EventArgs e) { if (rtbEditor.SelectionLength == 0) return; string selectedText = rtbEditor.SelectedText; string[] lines = selectedText.Split(new[] { '\r', '\n' }, StringSplitOptions.None); List<string> newLines = new List<string>(); foreach (string line in lines) { if (string.IsNullOrWhiteSpace(line)) { newLines.Add(line); continue; } string trimmed = line.TrimStart(); if (trimmed.StartsWith("#")) { // 移除第一个 # 及其后的空格 string afterHash = trimmed.Substring(1).TrimStart(); string indent = line.Substring(0, line.Length - trimmed.Length); newLines.Add(indent + afterHash); } else { newLines.Add(line); } } string newText = string.Join("\r\n", newLines); rtbEditor.SelectedText = newText; }玄学细节:
Split(StringSplitOptions.None)保留原始换行符结构,避免"\r\n"被拆成两个空行;TrimStart()后再Substring(1)是为了安全移除#后可能存在的多个空格,确保“取消注释”后格式干净。
5. 避坑指南:那些让产线同事凌晨三点打电话给你的 RichTextBox 陷阱
这些不是文档里写的“注意事项”,而是我在三个不同工厂 MES 项目中,被现场电话轰炸后记下的真实翻车现场。每一条都附带现象、根因和可立即抄走的修复代码。
5.1 现象:在高 DPI 缩放(125%/150%)的 Win10 电脑上,RichTextBox 滚动条消失或错位
原因:RichTextBox 默认不支持 DPI 感知,缩放后控件尺寸计算失准,滚动条渲染区域被裁剪。
解决:在Program.cs的Main方法顶部添加 DPI 感知声明,并禁用自动缩放:
[STAThread] static void Main() { // 添加此行,启用系统 DPI 感知 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } // P/Invoke 声明 [DllImport("user32.dll")] private static extern bool SetProcessDpiAwarenessContext(IntPtr value); private const IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = (IntPtr)(-4);提示:仅靠
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2)不够,必须调用原生 API。VS2015 项目需手动添加user32.dll引用。
5.2 现象:加载 10MB 以上日志文件时,RichTextBox 卡死超过 30 秒,CPU 占用 100%
原因:RichTextBox 在Text属性赋值时会触发完整重绘,大文本下性能灾难。
解决:改用AppendText分块加载,并禁用重绘:
private void LoadLargeFile(string filePath) { rtbEditor.SuspendLayout(); // 关键!暂停布局和重绘 rtbEditor.Clear(); const int chunkSize = 64 * 1024; // 64KB 每次 using (var reader = new StreamReader(filePath, detectEncodingFromByteOrderMarks: true)) { char[] buffer = new char[chunkSize]; int read; while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) { string chunk = new string(buffer, 0, read); rtbEditor.AppendText(chunk); } } rtbEditor.ResumeLayout(); // 恢复重绘 rtbEditor.ScrollToCaret(); // 滚动到末尾 }5.3 现象:用户用鼠标拖选大段文本后,松手瞬间光标跳到文件开头
原因:RichTextBox 在SelectionChanged事件中调用ScrollToCaret()会重置滚动位置。
解决:记录原始滚动位置,仅在必要时恢复:
private int lastVerticalScroll = 0; private void rtbEditor_VScroll(object sender, EventArgs e) { lastVerticalScroll = rtbEditor.GetScrollPos(SB_VERT); } private void rtbEditor_SelectionChanged(object sender, EventArgs e) { // ... 其他逻辑 // 注释掉原有的 ScrollToCaret() // rtbEditor.ScrollToCaret(); // 改为仅当光标移出可视区时才滚动 Rectangle rect = rtbEditor.GetPositionFromCharIndex(rtbEditor.SelectionStart); Rectangle clientRect = rtbEditor.ClientRectangle; if (rect.Top < clientRect.Top || rect.Bottom > clientRect.Bottom) { rtbEditor.ScrollToCaret(); } }5.4 现象:粘贴从 Excel 复制的表格时,RichTextBox 显示为乱码方块
原因:Excel 复制的是 HTML 或 UnicodeText 格式,RichTextBox 默认尝试解析 RTF,失败后回退为乱码。
解决:在Paste菜单项中强制提取纯文本:
private void tmiPaste_Click(object sender, EventArgs e) { if (!Clipboard.ContainsText(TextDataFormat.UnicodeText)) return; string plainText = Clipboard.GetText(TextDataFormat.UnicodeText); // 清理 Excel 特有的制表符和换行符 plainText = plainText.Replace("\t", " ").Replace("\r\n", "\n"); rtbEditor.Paste(plainText); }5.5 现象:程序退出时,未保存的修改提示框点击“取消”后,窗体仍关闭
原因:FormClosing事件中e.Cancel = true未阻止默认关闭流程。
解决:在FormClosing中检查修改状态,并手动取消:
private void Form1_FormClosing(object sender, FormClosingEventArgs e) { if (rtbEditor.Modified) { DialogResult result = MessageBox.Show("文档已修改,是否保存?", "确认", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning); if (result == DialogResult.Yes) { if (!SaveCurrentFile()) e.Cancel = true; // 保存失败则不退出 } else if (result == DialogResult.Cancel) { e.Cancel = true; // 关键!阻止关闭 } } }6. 进阶技巧:用 RichTextBox 实现“只读日志查看器”与“可编辑配置区”的双模切换
产线工具常需同一界面承载两种角色:左侧是只读日志流(实时追加、自动滚动、禁止编辑),右侧是可编辑的设备参数配置区(支持撤销、注释、保存)。很多人用两个 RichTextBox 控件硬切,结果内存暴涨、焦点管理混乱。其实只需一个 RichTextBox,通过属性组合实现“逻辑双模”。
6.1 定义双模状态枚举与切换方法
public enum EditorMode { ReadOnlyLog, // 只读日志模式:禁用编辑、自动滚动、无撤销 EditableConfig // 可编辑配置模式:启用全部功能 } private EditorMode currentMode = EditorMode.EditableConfig; public void SetEditorMode(EditorMode mode) { currentMode = mode; switch (mode) { case EditorMode.ReadOnlyLog: rtbEditor.ReadOnly = true; rtbEditor.ShortcutsEnabled = false; rtbEditor.UndoLimit = 0; // 彻底禁用撤销栈 rtbEditor.ScrollBars = RichTextBoxScrollBars.Vertical; break; case EditorMode.EditableConfig: rtbEditor.ReadOnly = false; rtbEditor.ShortcutsEnabled = false; // 仍由我们接管快捷键 rtbEditor.UndoLimit = 100; rtbEditor.ScrollBars = RichTextBoxScrollBars.Both; break; } UpdateMenuState(); // 刷新右键菜单 }6.2 日志模式下的高效追加:避免闪烁与卡顿
只读日志的核心是“追加即显示”,但频繁AppendText会触发重绘抖动。我们用SuspendLayout+AppendText+ResumeLayout组合,并控制滚动:
private void AppendLogLine(string line) { if (currentMode != EditorMode.ReadOnlyLog) return; rtbEditor.SuspendLayout(); // 防止日志爆炸:限制最大行数(如 10000 行) const int maxLines = 10000; if (rtbEditor.Lines.Length > maxLines) { string[] lines = rtbEditor.Lines; string[] keepLines = lines.Skip(lines.Length - maxLines).ToArray(); rtbEditor.Lines = keepLines; rtbEditor.SelectionStart = rtbEditor.TextLength; } rtbEditor.AppendText(line + "\r\n"); // 仅当光标在末尾时才滚动,避免用户手动拖动后被强制拉回 if (rtbEditor.SelectionStart == rtbEditor.TextLength) { rtbEditor.ScrollToCaret(); } rtbEditor.ResumeLayout(); }6.3 配置模式下的结构化编辑:用正则预校验防止非法输入
设备配置文件常有严格格式,如BAUDRATE=115200。我们可在TextChanged中实时校验,并高亮错误行:
private void rtbEditor_TextChanged(object sender, EventArgs e) { if (currentMode != EditorMode.EditableConfig) return; // 示例:校验所有行是否符合 KEY=VALUE 格式 string[] lines = rtbEditor.Lines; for (int i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); if (!string.IsNullOrEmpty(line) && !line.StartsWith("#") && !Regex.IsMatch(line, @"^\w+\s*=\s*.+$")) { // 高亮整行背景为浅红 rtbEditor.Select(rtbEditor.GetFirstCharIndexFromLine(i), line.Length); rtbEditor.SelectionBackColor = Color.LightCoral; break; // 只高亮第一个错误 } } // 清除其他行的高亮(简化版,实际项目中建议用更精细的范围管理) rtbEditor.SelectAll(); rtbEditor.SelectionBackColor = Color.White; rtbEditor.SelectionLength = 0; }我的习惯:在产线工具中,从不依赖用户“自觉遵守格式”。而是用 RichTextBox 的
SelectionBackColor做即时视觉反馈,比弹窗警告更高效。用户看到红色高亮,自然会修正——这比写 100 行验证逻辑再弹 5 个 MessageBox 更接近真实工作流。
希望帮到你。
本文还有配套的精品资源,点击获取