如何用c#編寫文本編輯器【2005-8-24版】
南京千里獨行2005版權所有,不限轉載,請保留版權聲明
摘要
本文探討了使用c#從底層開發一個帶格式的文本編輯器的任務,深入探討了其中的文檔對象模型的設計,圖形化用戶界面的處理和用戶操作的響應,說明了其中的某些技術問題和解決之道。
前言
小弟從大學里開始接觸編程也有6年了,工作4年也是干編程的活,見過不少程序,自己也編過不少,在學校編程自己覺得是搞藝術品,其實玩一些游戲,比如文明法老王星際等從某些角度看也是搞藝術品,看著自己苦心經營的建筑物和人員由少變多,由簡單變復雜,心中有些成就感。編程也一樣,程序從幾十行寫到上萬行,功能由hellowword到相當復雜而強大,心中也有不少成就感。
畢業后工作,才漸漸感悟軟件開發本質上是做一個工具,這個工具給別人或者自己用。有了工具,很多問題就迎刃可解了。如此開來偶們程序員和石匠鐵匠木匠是同一類人了。不過沒什么,程序員本來就沒高人一等,人在社會,認認真真的工作就行了。
問題
廢話不多說了,現在談談標題提出的問題,如何用c#編寫文本編輯器。本人有幸開發過一個比較復雜的文本編輯器,因此也算有點經驗吧,在此來分享一下。這里所指的文本編輯器不是簡單的像windows自帶的單行或多行文本編輯框,而是類似于word的文本編輯器。
粗看起來,一個編輯器有什么好難的,其實很難的,因為我們認為容易的事對計算機來說確實天大的問題。比如大家經常上網,可以發現最近幾年很多網站登錄時除了輸入用戶名和密碼后還要輸入所謂的驗證碼,而驗證碼則在輸入框旁邊歪歪扭扭的畫了出來,就像小學一年紀的學生在一張臟紙上寫的一樣,這樣做只是為了防止程序來模擬登錄,因為歪歪扭扭的文字人類可以很容易的辨認,而計算機則很不容易辨認。
一個文本編輯器主要處理的問題有
一個完整的功能不弱的文本編輯器結構是很復雜的,涉及到的問題非常廣泛,沒有數萬行的代碼是搞不定的,這些問題在本文是不可能一一列出來并進行討論,在此只好挑一些重點來說說。
文檔對象模型
在實際開發時不必挨個解決問題,我是首先確定文檔對象樹的結構,這里使用了文檔對象模型的概念,其實我們已經碰到很多種文檔對象模型,最多的莫過于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。
這種動作處理的模式還便于程序進行擴展,其他應用程序也可往動作列表中添加自定義的動作對象,這樣文本編輯器就能自動應用該動作。應用程序還可修改各種動作的熱鍵設置來實現用戶操作的個性化。
派生對象
定義了基礎對象后就開始派生對象了,首先定義字符對象類型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函數。
新聞熱點
疑難解答