项目系统架构如下图所示:
UI元素即HTML DOM元素。项目打开时,画面虽然是一片空白的,但实际上UI元素并不空白。一些可复用不需动态创建的元素可预先定义好。例如选择框、对齐线等,出现的时候它们只是位置发生了改变,不需动态创建,可预先定义好,节省开销。
UI元素使用ID标注(Vue里使用ref),然后注册到UI管理器中。后面对UI管理器中相应的对象进行修改时,UI元素就会发生改变。
对UI元素的所有操作,例如动一下鼠标、点一下按钮、按一下键盘,所有的这些事件都会交给事件处理器处理。事件处理器包括鼠标事件、基本编辑事件、画布事件、图元属性改变事件、分组操作事件、锁定操作事件、层次操作事件等。
事件处理器不会对UI直接产生作用,它们会调用各种管理器去完成任务。每种元素都会有一个管理器,包括画布、光标、历史记录、整图、选择、状态、选框、文字、变换等。
管理器中会使用到一些比较复杂的操作或计算,这些功能由工具类提供。例如几何计算、思维导图位置计算、path数据生成等。
管理器最后会对UI管理器进行操作,达到改变UI的最终效果。
以下,我们以鼠标点击一个矩形为例,说明框架的运行细节。
HTML中定义了一个选择框,包括一个虚线矩形和八个实线小矩形。
上述图元注册到UI管理器中。
当鼠标点击矩形时,触发了MouseDown事件,这个事件会调用鼠标事件处理器。
鼠标事件处理器利用目标工具类,判断当前鼠标点击了什么目标。
鼠标事件处理器利用状态管理器获取当前状态,继而改变当前状态。
鼠标事件处理器利用光标管理器修改当前光标。
鼠标事件处理器利用选框管理器,显示选择框。
选框管理器调用UI管理器中注册的元素,把选择框显示出来。
图元数据描述是本项目开发的一个难点。图元数据的特点包括:
(1)图元种类众多,每种图元有自己独特的属性。
(2)某些种类的图元存在公共的属性。例如矩形、图片的位置都是通过一个矩形来描述的。
(3)管理器对图元有共同的操作。例如把图片数据转化为可视HTML,鼠标拖动时的平移动作等。这些操作在图元内部的实现并不一致。
(4)图元存在一些即使而不需要保存的属性,例如是否选中,这是一个不需要保存,每次打开时都有默认值的属性。
在本项目中,图元具有以下的数据定义:
class Component { public id: string = ""; public type: ComponentType = "rect"; public props: { [key: string]: string } = {}; public connectAnchors: Array= []; }
这就是图元的基类。可以看到,属性是相当少的。在这里,需要说明几种跟TypeScript语法相关的设计特性:
(1)图元基类只有属性,没有方法。这是因为图元需要进行复制操作,复制时方法是无法实现的。
(2)很多特有的属性被设计到props属性中,这是一个Object。实际上Map也能实现相同的功能,但因为Map同样不能被复制,所以使用Object。props中只存放字符串类型的属性,其他属性(例如位置)如果使用字符串,还需要转换,浪费资源。
对于存在公共属性的图元,定义了继承Component的新的基类。例如,用矩形去描述位置的图元,都继承于以下的基类:
class RectPositionComponent extends Component { public position: Rect = new Rect(0, 0, 0, 0); public originPosition: Rect = new Rect(0, 0, 0, 0); public mindChildren1: Array= []; public mindChildren2: Array = []; public constructor() { super(); this.props[PreDefine.MindType] = PreDefine.DefaultMindType; } }
在这里,我们可以看到,这个类有一个position属性,它是一个矩形。还有一个originPosition属性,是用在变换图元时标记原始位置的属性。
这个基类还描述了思维导图的数据结构。本项目的思维导图,只能在矩形类型的图元下创建。思维导图存在树在两侧的展示方式,所以有两棵树(mindChildren1和mindChildren2)去描述思维导图的结点。
以下是矩形类的定义:
class RectComponent extends RectPositionComponent { public constructor() { super(); this.type = "rect"; this.id = CommonFunc.GetId(); this.props[PreDefine.Fill] = PreDefine.DefaultFill; this.props[PreDefine.Stroke] = PreDefine.DefaultStroke; this.props[PreDefine.StrokeWidth] = PreDefine.DefaultStrokeWidth; this.props[PreDefine.StrokeDash] = PreDefine.DefaultStrokeDash; this.props[PreDefine.CornerRadius] = PreDefine.DefaultCornerRadius; this.props[PreDefine.TextHorAlign] = PreDefine.DefaultTextHorAlign; this.props[PreDefine.TextVerAlign] = PreDefine.DefaultTextVerAlign; this.props[PreDefine.FontFamily] = PreDefine.DefaultFontFamily; this.props[PreDefine.FontSize] = PreDefine.DefaultFontSize; this.props[PreDefine.FontColor] = PreDefine.DefaultFontColor; this.props[PreDefine.FontBold] = PreDefine.DefaultFontBold; this.props[PreDefine.FontUnderline] = PreDefine.DefaultFontUnderline; } public textPos: AnchorPos = new AnchorPos(); public textExist: boolean = false; public textHtml: string = ""; }
矩形的样式,需要通过填充颜色、描边宽度等属性去定义。另外,矩形中还有文字,文字又有字体大小、字体颜色等属性。这些属性都在图元初始化时添加到props属性中。而文字的位置等属性,由于其存在一定的数据结构,如果定义在props中,还需要转化,浪费资源,所以独立定义。事实上,这种区分定义还有一个重要原因,就是在菜单中能修改的属性都在props中。
对图元的操作由一个接口去定义。以下是该接口的部分方法:
interface IComponentOperate { ChangeId(): void; Create(obj: unknown): void; Html(): string; UpdateHtml(): void; DeleteHtml(): void; Move(diagramX: number, diagramY: number): void; MoveOffset(offsetX: number, offsetY: number): void; UpdateInnerPosition(): void; MarkOrigin(): void; ChangeCoordinate(origin: Rect, target: Rect): void; }
Html()方法是返回图元的HTML内容。例如矩形使用一个
标签定义,形状一般使用
标签定义,而图片使用
标签定义。又如,UpdateInnerPosition()方法一般是用在组类型的图元上的。一个组进行变换之后,其内部的所有图元都要跟随变换。但矩形这种图形,内容并没有子,也就不需要执行额外的操作。
操作方法跟图元,通过图元的ID和类型进行绑定。图元的类型确定了接口实例化的方法类,而方法类要处理哪个图元,又通过ID去确定。