摘要
本文探讨了使用C#从底层开发一个带格式的文本编辑器的任务,深入探讨了其中的文档对象模型的设计,图形化用户界面的处理和用户操作的响应,说明了其中的某些技术问题和解决之道。
前言
小弟从大学里开始接触编程也有6年了,工作4年也是干编程的活,见过不少程序,自己也编过不少,在学校编程自己觉得是搞艺术品,其实玩一些游戏,比如文明法老王星际等从某些角度看也是搞艺术品,看着自己苦心经营的建筑物和人员由少变多,由简单变复杂,心中有些成就感。编程也一样,程序从几十行写到上万行,功能由HellowWord到相当复杂而强大,心中也有不少成就感。
毕业后工作,才渐渐感悟软件开发本质上是做一个工具,这个工具给别人或者自己用。有了工具,很多问题就迎刃可解了。如此开来偶们程序员和石匠铁匠木匠是同一类人了。不过没什么,程序员本来就没高人一等,人在社会,认认真真的工作就行了。
问题
废话不多说了,现在谈谈标题提出的问题,如何用C#编写文本编辑器。本人有幸开发过一个比较复杂的文本编辑器,因此也算有点经验吧,在此来分享一下。这里所指的文本编辑器不是简单的像Windows自带的单行或多行文本编辑框,而是类似于Word的文本编辑器。
粗看起来,一个编辑器有什么好难的,其实很难的,因为我们认为容易的事对计算机来说确实天大的问题。比如大家经常上网,可以发现最近几年很多网站登录时除了输入用户名和密码后还要输入所谓的验证码,而验证码则在输入框旁边歪歪扭扭的画了出来,就像小学一年纪的学生在一张脏纸上写的一样,这样做只是为了防止程序来模拟登录,因为歪歪扭扭的文字人类可以很容易的辨认,而计算机则很不容易辨认。
例子:注册hotmail使用的验证码,其显示的字符为 8UV9BKYR 。
一个文本编辑器主要处理的问题有
一个完整的功能不弱的文本编辑器结构是很复杂的,涉及到的问题非常广泛,没有数万行的代码是搞不定的,这些问题在本文是不可能一一列出来并进行讨论,在此只好挑一些重点来说说。
文档对象模型
在实际开发时不必挨个解决问题,我是首先确定文档对象树的结构,这里使用了文档对象模型的概念,其实我们已经碰到很多种文档对象模型,最多的莫过于HTML文档对象模型,我们用JavaScript来控制HTML页面内容时就是使用HTML文档对象模型,此外还有XML文档对象模型,VBA操作的是Word或Excel文档对象模型。使用文档对象模型,可将文档中所有的内容和内存中的某个对象联系起来,当应用程序修改了内存的对象的数据,则相应的文档内容就修改了。删除了内存中的对象也就删除了相应的文档内容。一些文档对象模型的思想可以参考http://www.w3.org。
文档对象模型中有很常见的是对象的继承和重载。大家可以看看.NET类库的System.XML名称空间下定义的XML文档对象模型,你可以发现无论是XML文档对象(XMLDocument),XML节点(XMLElement)还是属性(XMLAttribute),甚至注释(XMLComment)纯文本数据(XMLText)都是从抽象类XMLNode继承过来的。这样设计的好处是可以很方便的遍历XML文档对象树,各种对象都是从XMLNode派生的,都根据各自需要重载一些成员方法,其他程序都可把这些对象都看作XMLNode来使用,利用对象方法的重载和多态性来实现各自不同的处理。
基础对象
在这种指导思想下,我也定义了一个抽象类TextElement,所有的文档对象都是从该对象派生的。该类定义了以下虚成员
由于文档内容是分层次的,因此还定义一个容器类型TextContainer,该类型从TextElement派生的,其中进行扩展来可以保存若干个子对象,它定义了以下虚成员
在某些容器对象中存在一个特殊的子元素,该子元素为最后一个元素,并且不能删除,比如对于段落对象,在此是一种容器对象,该对象最后一个元素为一个段落结尾标记对象,该对象不能删除,而在其他类型的容器对象中也可能存在类似的结尾对象,因此在TextContainer对象中就考虑这种情况,因此定义了一套虚成员来处理
TextContainer对象还重载RefreshSize方法来重新计算所有子元素的显示大小,此外还定义了新的虚方法RefreshLine来进行分行处理,为了方便分行处理,还定义了文档行对象TextLine,文档行对象用于保存文档内容分行信息,当文档分行完毕而内容没有发生改变时重新绘制文档内容时就无需重新计算要显示的内容的坐标,文档行对象的成员有
为了保存分行信息,TextContainer对象还定义了一个Lines只读属性,该属性返回System.Collections.ArrayList对象列表,该列表元素为属于该容器的所有文本行对象,容器对象执行RefreshLine进行分行的步骤为
其实关于分行操作应当还有更优化的方法,但本人能力有限,只能提出这种方法。试验证明,在处理小的文档时程序运行速度还行,但当文档内容很多,有数万个字符时,分行速度就很慢,还望高手提供解决之道。
为了表示整个文档对象,还定义了文档对象TextDocument ,该对象在文档对象模型中是个最大的对象,我没有模仿其他文档对象的模式将其从TextElement派生过来的,而是直接定义的。该对象用于从整体上操作文档,并列出了一些操作文档的基本操作,比如删除,复制粘贴等。此外还提供一套方法来实现VBA的功能。
此外还定义了文档内容管理对象Content ,该对象隶属于TextDocument对象,用于管理所有的文档元素,它定义了属性Elements,该属性为一个保存了文档所有元素对象的列表。该对象还定义了属性SelectStart来表示插入点的位置,SelectLength 来表示选择区域的长度,为0表示没有选中任何元素,为正数则表示从插入点向后选中了若干个元素,为负数则表示从插入点向前选中了若干个元素。本对象还定义了一套处理插入点的函数,比如向左向右移动若干个元素,向上向下移动一行。大家都知道,在文本框中可以直接用光标键来移动插入点,也可以使用光标键时同时按下Shift键来移动插入点并选择文档内容,用户也可以用鼠标点击操作来移动插入点,鼠标点击的同时按下Shift键也能移动插入点选择文档内容;为此在Content对象定义了属性AutoClearSelection,当设置了该属性则移动插入点时设置SelectLength为0,若没有设置该属性则移动插入点时设置SelectLength值,使得新插入点和旧插入点之间的元素被选中,这样文本编辑器根据用户是否按下Shift键来设置AutoClearSelection属性就行了。用户修改了插入点和选择区域,则文本编辑器需要重新绘制用户界面,此时需要优化,只重新绘制选择状态发生改变的元素。可以证明,当选择的元素为连续的,则无论如何的修改选择区域和插入点,最多只有两片区域中的元素的选择状态发生改变。因此只要获得这两片区域的起始位置和长度,然后重新绘制这两个区域中的元素即可。
用户可以对文档进行很多种操作,比如移动插入点,选择元素,设置字符的字体颜色和大小,插入文字和图片,修改元素的设置,删除剪切复制粘贴等等,有好几十种操作,而且这些操作在某个时刻是不可用的,需要进行判断,若这些操作都在TextDocument中定义相应的接口函数,则TextDocument类代码太多,过于臃肿,而且每新增一种操作都需要修改TextDocument,因此在此提出动作这个概念。动作就是一个实现某种文档操作的类型,该类型有统一的接口,并使用TextDocument或其他对象提供的基本的操作来实现比较复杂的操作。为此定义动作基础类EditorAction,该类为抽象类,它的主要接口有
各种实际的动作对象都是从EditorAction派生的,若对象有热键则在初始化时设置HotKey字段,首先重载ActionName给定一个名称,然后重载Execute来实现各自的动作处理过程,还可根据需要重载isEnable或TestHotKey。
在TextDocument中有个属性Actions,该只读属性为包含各种动作对象的列表,当TextDocument初始化时就初始化该动作对象列表,当文本编辑器获得输入焦点时按下键盘按键则程序会遍历Actions中所有的动作,进行热键判断,若命中热键则执行该动作,其他应用程序也可根据各个动作的isEnable属性来设置文本编辑功能按钮和相应菜单的可用性。
比如定义复制动作对象EditorCopyAction,该类型从EditorAction派生的,重载ActionName使其返回"copy";重载isEnable,当文档有被选中的部分则返回True否则返回False,重载Execute来调用TextDocument中实现复制功能的函数,该对象初始化的时候设置HotKey为 System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.C,这样定义了该动作的热键为Ctl+C。
这种动作处理的模式还便于程序进行扩展,其他应用程序也可往动作列表中添加自定义的动作对象,这样文本编辑器就能自动应用该动作。应用程序还可修改各种动作的热键设置来实现用户操作的个性化。
其实这种动作处理的模式我是看了SharpDevelop的文本编辑器部分的源代码而领悟的,拿过来用用,实践证明还是很不错的。
我既然做的是文本编辑器当然支持复制粘贴功能了,首先将将复制操作。程序可以同时向Windows剪贴板发送多种格式的数据,这些数据可以是纯文本的,也可以是图象或者自定义格式,其他程序在进行粘贴操作是可以选择其中所需格式的数据。例如大家在VS.NET的代码窗体中复制某段代码,粘贴到Word和记事本中的结果是不一致的,虽然文本内容是一样的,但粘贴到Word中连代码文本的颜色也显示出来的,而记事本则是纯文本数据。大家可以用剪贴板查看器clipbrd.exe来实时查看Windows剪贴板中的内容。在.NET中向剪贴板发送数据还是比较方便的,首先实例化一个System.Windows.Forms.DataObject对象,调用它的SetData方法,该方法第一个参数为格式的名称,第二个参数为数据,可以多次调用该方法来保存不同格式的数据,然后调用静态库函数 System.Windows.Forms.Clipboard.SetDataObject 方法即可。在这个文本编辑器中复制数据时同时向系统剪切板保存两种数据,首先保存文档中被选中部分的纯文本数据,然后将被选中的部分转换为一个XML字符串,然后使用自定义的格式名称保存进去。这样其他程序就能使用其中的纯文本数据了。程序在进行粘贴操作时首先调用静态库函数System.Windows.Forms.Clipboard.GetDataObject方法,获得一个 实现了System.Windows.Forms.IDataObject接口的对象,然后调用它的GetDataPresent方法,若发现其中有我自定义的数据则读取该数据,然后将其中的数据当作字符串取出来,这是一个XML字符串,解析该XML字符串,并生成一系列的文档元素对象插入到文档当前位置,这种粘贴操作能将所有的文档元素及其格式给粘贴过来。若没有自定义数据但是有纯文本数据,则读取纯文本数据,并根据文本生成一系列文本元素对象,然后插入到文档当前位置。
VBA
文档对象还支持VBA,.NET框架支持VB.NET脚本语言,.NET类库中的类 Microsoft.VisualBasic.Vsa.VsaEngine及接口 Microsoft.Vsa.IVsaSite 就支持脚本语言。我参照HTML文档对象模型,在VB.NET的基础上设计一种处理文档的脚本语言,该语言中直接使用脚本全局对象document就访问了文档对象TextDocument,而使用document.all就能访问文档中的某些做了标记的文档元素对象,使用 dbconnection 就能使用文本编辑器后台使用的数据库连接对象,使用eventobj访问文档编辑器触发的事件的信息,使用vbsystem来调用某些例程。首先定义一些类型,用于实现脚本全局对象dbconnection,eventobj,vbsystem的功能,而全局对象document的类型就是TextDocument,已经实现,但document.all还未实现,为此在TextDocument中新增只读属性all,该属性返回一个System.Object类型的对象,由于document.all的类型中定义的字段根据文档的内容而动态改变,因此需要使用.NET的反射机制动态的创建对象类型并实例化对象,其创建过程为
这样应用程序动态的创建了AllElements类型并实例化了一个对象引用,这时VB.NET脚本程序就可以直接使用 document.all.文档元素对象名称 来直接访问文档中特定内容了。注意当文档内容发生改变时需要重新生成AllElements的类型并实例化。
以上的程序模块建好后就可以搭建VB.NET脚本语言运行环境了,首先定义类型TextDocumentVsaSite来实现IVsaSite接口,实现其中的GetGlobalInstance函数,该函数参数为字符串,返回一个对象,该函数实际上判断若参数"document"则返回文档对象TextDocument ,若参数为"eventobj"则返回刚刚定义了事件对象,若为"dbconnection"则返回数据库连接对象。该对象还实现了IVsaSite.OnCompilerError来处理脚本编译错误。
程序还从Microsoft.VisualBasic.Vsa.VsaEngine派生了脚本引擎VBScriptEngine。该模块使用VsaEngine的Items.CreateItem来向引擎添加document,eventobj,dbconnection等全局变量,还添加一些所需的.NET引用,此外还实现了对脚本代码文本的一些处理,比如加密,自动添加某些必须的代码等。
脚本环境还模拟实现了文档事件的处理,比如文档中某些元素对象支持onchange事件,这些元素是有名称的,当用户修改这些元素的内容时,程序会查询脚本引擎来看是否存在名为对象名称_OnChange的过程存在,若存在则执行它,这样就模拟实现了事件处理。
在VB.NET脚本环境中,全局对象的成员函数可以直接调用,因此在vbsystem中定义一些例程就可以直接调用,可以在vbsystem中定义诸如Alert,ConFirm,Prompt,DebugPrint等成员函数,脚本中就能直接使用这些函数了。
访问数据库
由于应用需要,本文本编辑器要直接访问数据库,但该文本编辑器既使用于C/S程序又使用于B/S程序,当处于B/S构架时是不好直接连接数据库的,必须通过服务器程序来访问数据库。为了编程方便,应该抹杀掉这两种模式之间的差别。
大家考察一下.NET框架中操作数据库的类型,可以发现无论是专门操作SQLServer的在System.Data.SqlClient名称空间下面的那套对象还是操作OLEDB的在System.Data.OleDb空间下面的那套对象(其他类似有专门操作ODBC和ORACLE),这些套对象间最大的共同点就是都遵循一套在名称空间System.Data下接口。这些接口包括IDataReader , IDbCommand,IDbConnection,IDbDataParameter, IDataParameterColleciton等等 。若我们编了一套对象也实现了这些接口,那就相当于自定义了一套.NET数据库驱动程序。于是小弟很快根据B/S构架特性写了套对象,该套对象通过HTTP协议和WEB服务器交流数据,这套对象将SQL语句及其参数简单打包使用POST方法发送到指定的服务器页面后等待返回,服务器页面解析出SQL语句和参数查询数据库,将查询所得结果经过一定的编码返回为客户端,而客户端根据HTTP返回结果进行一番处理后就可以使用一个实现IDataReader的对象来访问了。这样在应用程序的其他模块若查询数据库则只要坚持使用System.Data.IDbConnection 等接口就可以了,如此就抹杀了C/S和B/S环境下访问数据库的差别了。
这种模式也算是一种WebService了,服务器页面可以使用任何类型,可以使用ASP,ASP.NET,PHP,J2E或JSP等等,只要能解析出SQL语句并返回特定结构的数据就行了。小弟的服务器为J2E,偶JAVA不熟,勉强用JSP实现了一个。我管这种模式叫两层半,实践证明这套还是管用的。
派生对象
定义了基础对象后就开始派生对象了,首先定义字符对象类型TextChar,一个文档内容中最主要的还是字符数据,在此为了实现方便,文档中每一个字符都是一个字符对象,字符对象重载了RefreshSize对象RefreshSize方法,用于根据当前绘制用的绘图对象(System.Drawing.Graph对象)的MeasureString来计算文字大小。注意默认情况下,该方法计算的字符串显示宽度后回额外的附加一些空白,为了计算实际的大小则使用System.Drawing.StringFormat.GenericTypographic参数。此外还有一个比较特殊的字符-制表符。这个字符的宽度是不固定的,需要在进行排版的时候才计算。
字符对象(TextChar)还派生RefreshView方法,该方法比较简单,根据Left,Top值进行坐标转换后算出绘制地点,然后调用System.Drawing.Graph.DrawString方法即可。字符对象还定义了自己的成员,比如Char属性返回对象表示的字符数据,Font表示绘制对象使用的字体,ForeColor表示绘制文本的颜色。
字符中的制表符比较特殊,因为它的宽度是不定的,而是根据它在文档视图中的位置而定的,因此在TextChar上在派生TextCharTab来转变处理这种情况,它新增了RefreshTabWidth方法,来根据对象在视图区域中的左端位置计算字符宽度。在此处我认定一个制表符步长等于四个下画线字符的宽度,制表符的右端坐标必须是制表符步长的自然数倍,因此根据制表符的位置来进行取模操作和其他操作就可以计算制表符的宽度。
为了表示段落而定义了段落对象TextParagraph,该对象不是容器对象,保存了段落对齐方式的信息,该元素的显示样式类似于Word中的段落符(硬回车)的样式。
还定义了行结束对象TextLineEnd,该对象模拟了Word的分行符(软回车)。
可以定义图片对象,经过对Word处理文档的行为观察,可以发现在Word文档中插入的图片和OLE对象特性很相似,因此为了考虑文本编辑器的可扩展性,首先在TextElement的基础派生出TextObject抽象类,该抽象类表示一个在文档中的对象,该对象由其派生的类决定。
在TextObject对象派生出TextImage表示一个图片对象,该对象重写了RefreshView方法,用于在绘图输出对象上绘制一个图片。还重载了FromXML和ToXML方法来和XML节点交换数据,可以设计将图片二进制数据以Base64格式保存为XML节点下。
此外还可以根据应用的需要从TextObject对象上派生其他的类型,比如直接读取数据库在界面上绘制曲线图等等,此时文档中的该对象可以动态的展示系统中最新的数据。
图形化用户界面
可以观察到Word中的对象(包括图片)可以改变大小,当用鼠标点击图片对象时,图片四个角和四个边的中点上会显示8个小点。这些小点我称为控制点。用鼠标拖拽这8个点可以动态的改变对象的大小。其实在很多类型的程序中可以碰到这8控制点,例如在VS.NET的窗体设计器中,当前的控制周围就有这8个控制点。关于如何实现这8个控制点也是有一套的。
控制点可以分为内控制点和外控制点两种类型,我们对这8个点进行从0到7的编号。当鼠标光标移动到这8个控制点上方时需要设置为不同的光标样式。
内控制点
┌─────────────────┐
│■0 1■ 2■│
│ │
│ │
│ │
│ │
│■7 3■│
│ │
│ │
│ │
│ │
│■6 5■ 4■│
└─────────────────┘
外控制点
■ ■ ■
┌────────────────┐
│0 1 2│
│ │
│ │
│ │
│ │
■│7 3│■
│ │
│ │
│ │
│ │
│6 5 4 │
└────────────────┘
■ ■ ■
控制点上鼠标光标如下
西北-东南 SizeNWSE 南北 SizeNS 东北-西南 SizeNESW
■ ■ ■
┌────────────────┐
│0 1 2│
│ │
│ │
│ │
│ │
■│7 西-南 SizeWE 3│■ 西-南 SizeWE
│ │
│ │
│ │
│ │
│6 5 4 │
└────────────────┘
■ ■ ■
东北-西南 SizeNESW 南北 SizeNS 西北-东南 SizeNWSE
根据上图所示,已知主矩形,控制点的类型(是内控制点还是外控制点)和控制点的宽度可以计算出所有的控制点的位置。可以编一个例程,输入3个参数,主矩形区域的Rectangle结构体,是否是内控制点(不是内控制点就是外控制点)和控制点的宽度,该例程计算所有控制点的位置,然后返回一个包含8个Rectangle的数组,该数组就是0到7号的控制矩形的位置和大小。
TextObject对象显示后就应该知道自己在视图区域中的位置,当它相应鼠标移动消息时,就可以根据鼠标光标位置和8个控制矩形进行比较,若鼠标光标在某个控制矩形中时就要通知文本编辑器改变鼠标光标的样式。
一般的控制点被画成一个矩形方框,控制点也被画成两种类型,一种是填充色为深色(蓝色或黑色)和白色边框,另一种是深色边框并填充白色。可以观察VS.NET窗体设计器,可以在设计器中选择多个控制,其中有一个控件的控制点为填充色为蓝色和白色边框的,该控制为当前控件。而其他选择的控件的控制点为蓝色边框并填充白色,这些控件为选择控件。在文本编辑器中没有这种情况,因此在此可以使用内控制点方式,控制点用黑色填充,边框白色。
当鼠标在控制点上进行拖拽操作就应当可以动态的修改对象的大小,以前我是如此实现的
经过一些编程实践,发现该操作比较麻烦,需要编写不少代码,而且代码分散在3个事件处理过程中,多了一些全局变量,很难写出一个通用例程到处调用,经过分析,将这种处理模式改掉了。其实一般的程序正在进行鼠标拖拽操作时,用户是不可能同时进行其他操作(不如边鼠标拖拽边打字),而且进行”橡皮筋“操作时程序用户界面无需重新绘制,这样可以认为进行鼠标拖拽时应用程序应用程序只处理鼠标移动消息和鼠标松开消息而不进行任何其他操作,为了编程简单,甚至连重绘界面的操作也不处理了,因此可以编一个通用例程来处理整个的鼠标拖拽来实现“橡皮筋”操作,该函数处理过程为
在此插上一段,其实.NET框架还是比较适合Win32的API编程,System.Windows.Form.Control的Handle属性就是窗体的句柄,可以被其他Win32API作为参数调用,CreateParams属性实际上就是CreateWindowEx的参数,重载它就可以设置控件创建时的样式;WndProc就是控件处理所有的Windows消息的默认过程,也可以重载它自己来处理底层的Windows消息。System.Windows.Forms.Application的静态函数AddMessageFilter和RemoveMessageFilter就可以很方便的为整个应用程序添加或删除"钩子"程序。C#语言可以使用System.Runtime.InteropServices.DllImport来导入声明DLL文件中的API函数。
光标的控制
光标就是文本编辑器在获得输入焦点时在当前插入点闪烁显示的一个小方块,当文本编辑器没有获得输入焦点时就不会显示光标。Windows操作系统已经提供了一套处理光标的API函数,包括创建光标CreateCaret,设置光标位置SetCaretPos,显示光标ShowCaret和隐藏光标HideCaret。使用API创建和显示光标后,操作系统会自动的让光标闪烁。文本编辑器要提供处理光标的例程供其他程序模块调用。文本编辑器首先根据当前插入点的位置计算光标在文档视图区域中的位置,还需要根据文档处理插入模式还是修改模式计算光标的大小。若光标所在位置在用户界面上没有显示出来则还需要滚动文本编辑器已保证光标所在区域处于可视区域。然后调用API来创建和显示光标。文本编辑器还重载OnGotFocus过程来显示光标,重载OnLostFocus过程隐藏光标。注意重载这两个过程时需要在最后必须调用base.OnGotFocus和base.OnLostFocus,若不这样则文本编辑器嵌入在网页中运行会发生无法获得输入焦点的错误。光标控制还涉及到输入法的控制,我们中国人使用文本编辑器会使用到各种中文输入法。Windows操作系统也提供了一套API来控制输入法。在本文本编辑器中重载OnKeyPress方法来获得用户输入的字符,此时的字符可以是键盘直接输入的ASCI字符,也可以是使用某种输入法输入的汉字。这些操作系统都已经实现了,为什么还要控制输入法。原因是使用默认处理时输入法的浮动窗口会显示在屏幕中间而不会随着插入点的位置而移动。一般的中文输入法的用户界面为一个浮动窗口,各种建议输入的中文字符显示在这个浮动窗口中供人选择。想想看,当插入点在屏幕的某个边角中,而输入法的浮动窗口在屏幕中央,这样输入中文比较累。但若输入的浮动窗体随着插入点的移动而移动,浮动窗体和插入点紧密的靠在一起,这样输入中文就不是很累了。Win32API函数ImmSetCompositionWindow能对指定的窗体控制输入法浮动窗体的显示位置,当插入点改变时调用该API函数就能让输入法浮动窗体随着插入点跑了。