大家好,我是前端西瓜哥。
这篇文章是春节前的最后一篇文章,西瓜哥在这里提前祝大家新年快乐了。
在搭好图形编辑器的框架后,我们可能需要根据需求加入一些新的图形类型。
那么加入新的图形类型,需要做哪些工作呢?今天我们就来探究一下。
图形属性设计
首先是设计新图形的属性,因为我们需要把这些数据进行持久化。
比如圆形的 center、radius,多边形的 points、closed 等。
如果是做的竞品,可以参考竞品的数据结构,如果可以拿到的话。这对理解需求,以及减少后期破坏性修改是有很大帮助的。
不要保存冗余数据。
数据结构要尽量简洁,尽量不要保存一些多余的数据。
举个例子,对于矩形,x,y,width 和 height 是必要属性,但它的中点 centerX 和 centerY 就没有必要保存,它是基于前面 4 个属性的计算而来的 计算属性(其实算是一种缓存了)
另外这样可能还会出现数据不一致问题,如果保存的 centerX 和 centerY 和 x、y、width、height 计算出来的值不一致,那就会让人困惑,到底以谁为准呢。(更新维护缓存永远是让人头疼的问题)
但有一种计算属性可以考虑保存的,那就是计算性能开销大的数据,比如图形三角化的数据,复杂图形的包围盒,如果能够把它们保存下来,可以有效减少图纸的初次加载时间。
另外有一些属性是不会持久化的,它们只在内存中使用,比如图形可能会有版本号 version,会在属性更新时变更,可以用来判断是否某个版本的缓存是否还有效,持久化的时候则没有保存的意义。
图形渲染实现
设计好图形数据结构后,接着就需要基于这些属性去渲染图形。
对于基本图形,比如矩形、线、多边形,会使用渲染引擎去完成。渲染引擎可以自己实现,也可以基于开源的图形引擎,比如 Pixijs、ZRender,这里不多说。
然后是复杂图形。复杂图形通常就是基础图形的组合。这里会涉及到 一些简单的几何算法。
比如,下面是一个名为 "基准符号" 的简易版图形的渲染效果。

该图形使用的属性为:
-
startPoint:起点;
-
endPoint:终点;
-
size:矩形的尺寸,以及等边三角形的边长;
-
text:文字内容。
数据结构很精炼,该图形可以用一个三角形、一条线、一个矩形加一个文字组合而成。
一些简单图形的信息需要实现的通过算法得到,其中最重要的两个算法为:
-
两个点表示的等边三角形,求它的所有顶点;
-
给一条线段,求延长线经过大小为 size 的中心的邻接矩形及矩形中点。
这需要你掌握一些简单的几何算法知识的,涉及到向量、三角函数。
用简单图形进行组合,一般情况下看起来没什么毛病,但在一些场景会 "露馅",比如设置透明度的时候会看到颜色更深的重叠区域。
对于工业设计软件,这是用户可以接受的范围。
但如果是 UI 设计稿,那大概是不能接受了,这样也同样需要使用几何算法处理连接处过渡的。偷懒的话,可以找个布尔运算的库帮你处理(比如 Skia)。或者直接在渲染引擎里的三角化中计算。
绘制工具实现
图形设计好了,但用户怎么将图形绘制出来呢?
为此我们需要实现绘制工具,让用户通过鼠标和键盘,绘制图形的过程。
最简单的做法是,点一下,直接把图形放到画布中心上。或者拖拽到画布中,适合有大量图形类型的场景,这些图形通常用户也可以做一些简单的自定义。创建时不能定义属性值问题不大,只要之后能更改属性就行。典型代表有:drawio、Canva。
然后是交互好一些的,可以通过一些简单的鼠标行为完成图形的绘制。比如矩形,鼠标按下时确定矩形的左上角位置,鼠标释放确定第二个位置,构成一个矩形。典型代表有:Figma、Excaildraw。

然后就是极度复杂的交互,这个会在工业设计软件看到。
比如 AutoCAD 的一个绘制矩形工具,在绘制过程中可以通过输入命令,进入不同的子阶段,进而设置矩形的旋转角度、面积、宽高等值。
说真的,太复杂了,很多子阶段很少会用到,我不是很喜欢这种设计,感觉是为了复杂而复杂。
实现绘制工具的过程中,自然也离不开一些几何算法。比如吸附在某个图形的一条直线上,要实现正交效果,让绘制的点受到限制。
绘制图形可能有多个阶段,比如绘制多边线,用连续的多次鼠标按下释放绘制多个点,可能还要监听热键,将某段直线转换为绘制圆弧等等。
通过控制点更新属性
图形需要实现一个返回自定义控制点数组的方法。
可以使用配置化的方式,大概如下
javascript
class GraphA {
getHandlePoint() {
return [
{
type: 'cornerRadiusLeftTop', // 左上角圆角控制点
handleType: 'rect' // 控制点样式,使用矩形
x: 9,
y: 100,
size: 5
rotation:0.1342376,
}
// ...
]
}
}
图形编辑器框架会在必要的时候,比如当前图形被选中的时候,调用该方法拿到信息生成控制点,渲染在画布顶层。

以及要实现用户拖拽这些自定义控制点的方法。
typescript
updateAttrsByHandle(type,newPoint) {
if (type === 'cornerRadiusLeftTop') {
// ...
this.cornerRadius = newVal
}
// ...
}
属性面板更新属性
除了可以用控制点修改图形属性,还要支持通过属性面板显示和修改属性。
属性面板的属性值也可以用配置的方法实现。
javascript
getInfoPanelAttrs() {
return [
{
getVal: () => this.x,
uiType: 'number', // 输入框组件类型
precision: 2, // 组件配置项
},
{
key: () => this.text,
uiType: 'string',
maxLen: 18,
},
// ...
]
}
图形属性并不一定和 UI 上的属性一致。
例如我们用 rotation 属性保存旋转角,存的是弧度,对应的输入框考虑到用户体验,显示的值是角度值。
对此,我们需要实现两种数据格式的互转的方法。

其他业务逻辑
此外就是新的图形类需要重写的各种其他逻辑。
这个就看图形编辑器支持的高级功能的多少了,比如
-
复制粘贴处理,需要处理关联的其他对象。比如新的图形类型使用了一个全局的 style 对象,复制的时候就要把这个 style 也一起复制。
-
计算 bbox 包围盒方法
-
文件格式转换实现,比如 toSVG
-
...
兼容旧版编辑器
如果是单机软件,会有版本兼容问题。
比如 2024 版新增的图形类型,在 2023 版是无法识别的,代码里就没有对应的处理逻辑。
糟糕的做法是旧版软件加载新版图纸时,弹个弹窗,说 "不好意思,版本过低,请购买我们的最新版软件"。
稍微好一点点的是,可以打开图纸,然后把无法识别的图形类型都忽略掉,不渲染,但依旧半斤八两。
好的做法是 想办法显示出来,做法是让新的图形类型,额外保存一个基础图形组合。
css
{
type: 'newGraph2024'
extract: [
{ type: 'triangle', /**/ },
{ type: 'line', /**/ },
{ type: 'rect', /**/ }
]
}
这样旧版编辑器虽然没有新图形对应逻辑,但可以从 extract 数组中,拿到等价的图形进行渲染。当然新的图形类型特有的更新操作还是无法做到。
另外这个顺便还能实现图形的打散功能:一个图形分解为多个基础图形。
如果是联网才能用,不提供单机版,那就没有兼容问题。
因为用户每次打开网页,都是最新版的编辑器。此外,因为数据是保存在服务端的,甚至可以对已有图形类型进行破坏性修改,修复一些前期不合理的属性设计。
典型的例子是 Figma,它没有单机版本,即使桌面端也需要联网才能使用。
Figma 不愿意主动公开设计文件的格式,这样做其实就等价于公布了一个特定版本的单机软件,是需要对自己公开的格式负责的,这对 Figma 的后续文件格式调整是不利的。
当你需要为客户提供单机模式的软件,你可就要小心谨慎地设计数据结构了,你没有太多后悔药可吃的。
结尾
总结一下,加一个图形类型,需要做的工作有:
-
图形属性设计
-
图形渲染实现
-
绘制工具实现
-
控制点更新属性
-
属性面板更新属性
-
其他业务逻辑
-
兼容旧版编辑器
这里有很多逻辑并不需要你从零到一实现,是可以通过继承父类的方式复用的,你只需要重写部分的方法即可。
另外你需要解决大量的几何问题,通常都不难,但数量多,常用的几何算法可以统一放到一个包里,方便复用。
其他的业务逻辑通常框架帮我们做好了,倒没啥问题。不够有时候需求超出了框架本身的能力,这时候就要改改框架了。
我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。
相关阅读,