数据结构的设计,类似于DeltaSet
,最终呈现的数据结构形式是扁平化的,但是在Core
中需要设计State
来管理树形结构,因为要设计Undo/Redo
的功能,在不全量存储快照的情况下就意味着必须设计原子化的Op
,因为想实现的功能有组合这个能力,所以最终实现的形式实际上是树形的结构,而我希望的结构是扁平化的,因为树形结构查找起来比较费劲,需要实现的Op
类型也会变多,我希望能尽量减少Op
的类型并且能够做到History
,所以最终定下的数据结构是DeltaSet
作为存储,通过State
来管理整个编辑器状态。
原子化的Op
已经设计好了,所以在设计History
模块时就不需要全量保存快照了,但是如果每个操作都需要并入History Stack
的话可能并不是很好,通常都是有N
个Op
的一并Undo/Redo
,所以这个模块应该有一个定时器,如果在N
毫秒秒内没有新的Op
加入的话就将Op
并入History Stack
,但是当时我在思考一个问题,如果这N
毫秒内用户进行了Undo
操作应该怎么办,后来想想实际上很简单,此时只需要清除定时器,将暂存的Op[]
立即放置于Redo Stack
即可。
任何元素都是矩形,数据结构也是据此设计抽象出来的,在绘制的时候分为两层Canvas
重叠的方式,内层的Canvas
是用来绘制具体图形的,这里预计需要实现增量更新,而外层的Canvas
是用来绘制中间状态的,例如选中图形、多选、调整图形位置/大小等,在这里是会全量刷新的,并且后边可能会在这里绘制标尺。在实现交互的过程中我遇到了一个比较棘手的问题,因为不存在DOM
,所有的操作都是需要根据位置信息来计算的,比如选中图形后调整大小的点就需要在选中状态下并且点击的位置恰好是那几个点外加一定的偏移量,然后再根据MouseMove
事件来调整图形大小,而实际上在这里的交互会非常多,包括多选、拖拽框选、Hover
效果,都是根据MouseDown
、MouseMove
、MouseUp
三个事件完成的,所以如何管理状态以及绘制UI
交互就是个比较麻烦的问题,在这里我只能想到根据不同的状态来携带不同的Payload
,进而绘制交互。
在实现绘制的时候,我一直在考虑应该如何实现这个能力,因为上边也说了这里是没有DOM
的,所以最开始的时候我通过MouseDown
、MouseMove
、MouseUp
实现了一个非常混乱的状态管理,完全是基于事件的触发然后执行相关副作用从而调用Mask
的方法进行重新绘制。再后来我觉得这样的代码根本没有办法维护,所以改动了一下,将我所需要的状态全部都存储到一个Store
中,通过我自定义的事件管理来通知状态的改变,最终通过状态改变的类型来严格控制将要绘制的内容,也算是将相关的逻辑抽象了一层,只不过在这里相当于是我维护了大量的状态,而且这些状态是相互关联的,所以会有很多的if/else
去处理不同类型的状态改变,而且因为很多方法会比较复杂,传递了多层,导致状态管理虽然比之前好了一些可以明确知道状态是因为哪里导致变化的,但是实际上依旧不容易维护。最终我又思考了一下,决定在绘图这里实现类似于DOM
的能力,因为我想实现的能力似乎本质上就是DOM
与事件的关联,而DOM
结构是一种非常成熟的设计了,这其中有一些很棒的点子,例如DOM
的事件流,我不需要扁平化地调整每个Node
的事件,而是只需要保证事件是从ROOT
节点起始,最终又在ROOT
上结束即可,并且整个树形结构以及状态是靠用户利用DOM
的API
来实现的,我们管理之需要处理ROOT
就好了,这样就会很方便,下个阶段的状态管理是准备用这种方式来实现的。
在前边我们提到了我们想通过模拟DOM
来完成Canvas
的绘制与交互,那么在这里就很明显涉及到DOM
的两个重要内容,即DOM
渲染与事件处理。那么就先聊下渲染方面的内容,使用Canvas
实际上就很像将所有DOM
的position
设置为absolute
,所有的渲染都是相对于Canvas
这个DOM
元素的位置绘制,那么我们就需要考虑重叠的情况,那么想一个例子,A
的zIndex
是10
,A
的子元素B
的zIndex
是100
,C
与A
是平级的且zIndex
为20
,那么当这三个元素重叠的时候,在最顶部的元素是C
,也就是说zIndex
实际上只看平级元素,再假如A
的zIndex
是10
,A
的子元素B
的zIndex
是1
,那么在这两个元素重叠的时候,在最顶部的元素是B
,也就是说子元素通常都是渲染在父元素之上的。那么我们在这里也需要模拟这个行为,但是因为我们没有浏览器的渲染合成层,我们能够操作的只有一层,所以在这里我们需要根据一定的策略进行渲染,在渲染时我们与DOM
的渲染策略相同,即先渲染父元素再渲染子元素,类似于深度优先递归遍历的渲染顺序,不同的是我们需要在每个节点遍历之前,将子节点根据zIndex
排序来保证同层级的节点渲染重叠关系。
在渲染的基础上,我们还需要考虑事件的实现,例如我们的选中状态,八向调整元素大小的点一定是在选区节点的上层的,那么假如现在我们需要实现onMouseEnter
事件的模拟,那么因为Resize
这八个点位与选区节点是有一定重叠的,所以如果此时鼠标移动到重叠的点因为Resize
的实际渲染位置更高,所以只应该触发这个点的事件而不应该触发后边的选区节点事件,而实际上由于没有DOM
结构的存在我们就只能使用坐标计算,那么在这里我们最简单的方法就是保证整个遍历的顺序,也就是说高节点的遍历一定是要先于低节点的,当我们找到这个节点就结束遍历然后触发事件,事件的捕获与冒泡机制我们也需要模拟,实际上这个顺序跟渲染是反过来的,我们想要的是优点顶部的元素,优先更像树的右子树优先后序遍历,也就是把前序遍历的输出、左子树、右子树三个位置调换一下即可,但是问题来了,在onMouseMove
这种高频事件触发的时候,我们每次都去计算节点的位置并且采用深度优先遍历,是非常耗费性能的,所以在这里实现一个典型的空间换时间,将当前节点的子节点按顺序全部存储起来,如果有节点的变动,就直接通知该节点的所有每一层父节点重新计算,这里做成按需计算即可,这样当另一颗子树不变的时候还可以节省下次计算的时间,并且存储的是节点的引用,不会有太大的消耗,这样就变递归为迭代了,另外因为找到了当前的节点,在模拟捕获与冒泡的时候就不需要再递归触发了,通过两个栈即可模拟。
平时我做富文本相关的功能比较多,所以在实现画板的时候总想按照富文本的设计思路来实现,因为之前也说过要实现History
以及在编辑面板富文本的能力,所以焦点就很重要,如果焦点不在画板上的时候如果按下Undo/Redo
键画板是不应该响应的,所以现在就需要有一个状态来控制当前焦点是否在Canvas
上,经过调研发现了两个方案,方案一是使用document.activeElement
,但是Canvas
是不会有焦点的,所以需要将tabIndex="-1"
属性赋予Canvas
元素,这样就可以通过activeElement
拿到焦点状态了,方案二是在Canvas
上方再覆盖一层div
,通过pointerEvents: none
来防止事件的鼠标指针事件,但是此时通过window.getSelection
是可以拿到焦点元素的,此时只需要再判断焦点元素是不是设置的这个元素就可以了。
之前因为没有打算实现平移拖拽也就是无限画布的能力,但是后来真的开始通过这个主框架来实现想做的业务功能的时候发现这样是不行的,所以在后期想把这个能力加上,虽然本身这个能力并不复杂,但是因为最开始没有设计这个能力,导致后边做的时候有点难受,比如Mask
批量刷新频率不对齐、ctx
的translate
应该是偏移值取反、之前多处超出画布不绘制的计算有误等等,就感觉在没有设计的情况下突然增加功能确实是有点难受的,不过好处是不需要大规模重构,只是个别点位的修正。
此外多扯点别的,这个项目除了一些辅助性的工具例如resize-observer
以及组件库例如arco-design
都是自己写的,相当于实现了Canvas
的引擎,特别是在现在的core-delta-plugin-utils
结构设计下,是完全可以抽离处理作为工具包使用的,当然易用性与性能方面肯定比不上那些有名的开源框架。只不过今天我恰好看到了一个评论说的挺好的:如果是个人能力提升,那么最好是首先理解开源库,然后仿照实现开源库的功能,主要的目标是学习;而如果是商业化的使用,那就变成了知名的开源库优先,这样可以很大程度上降低成本。
在实现的过程中,绘制的性能优化主要有:
- 可视区域绘制,完全超出画布的元素不绘制。
- 按需绘制,只绘制当前操作影响范围内的元素。
- 分层绘制,高频操作绘制在上层画布,基础元素绘制在下层画布。
- 节流批量绘制,高频操作节流绘制,上层画布收集依赖批量绘制。
众所周知Canvas
绘制出来就是纯粹的图片,而实际使用导出PDF
的超链接是可以点击的,而我们当前就单纯只是图片无法做到这一点,所以需要解决这个问题,我想到的一个解决方案是在导出的时候,通过DOM
生成透明的a
标签,覆盖在原本的超链接位置,这样就可以实现点击跳转效果了。PDF
本身也是文件格式,所以是可以借助PDFKit/PDFjs
等PDF
排版生成工具来导出的,通过这种方式也可以直接在导出的时候直接将其写入固定位置,并且可以不受浏览器打印的分页限制。
前段时间有个issue
,是不清楚应该如何替换图片的问题,因为我最开始预期的交互是通过左上角的图形选择来完成这件事,所以在右侧面板并没有设计这个功能。那么隔天晚上我就在右侧面板把这个功能加上了,然后我测试了一下这个功能的效果,本来感觉效果还是可以的,但是我心血来潮在FireFox
上试了一下,就发现问题了,直接切换节点的时候无法正常渲染图片,必须要再触发一次渲染才可以。
图片的渲染是一个比较棘手的问题,因为Canvas
是无法直接渲染base64
数据的,而想要绘制图片就必须借助HTMLCanvasElement | ImageBitmap
类型的对象,但是无论是哪种都是异步加载的。例如我们使用Image
绘制图片,应该是需要在OnLoad
事件结束之后再绘制图片,但是这样会有一个问题,就是图片加载是异步的,所以我们的同步绘制就可能会出现图片未加载完成就开始绘制的情况,此时绘制在Canvas
上的图片是空白的。
最开始我们可能会想到如果是因为图片未OnLoad
导致图片绘制不了,那么我们不如直接在OnLoad
之后再触发一次图片绘制不就能解决这个问题了,如实际上如果只有图片在这里的话是没问题的,但是我们的图形很多,会存在重叠的问题,假如此时我们有图形A
和图片B
,并且A
的层级是在B
之上的,那么此时如果我们在OnLoad
再次绘制一次图片B
就会导致B
在A
之上,致使图形层级绘制出现问题。
那么如果直接将我们的同步绘制更改为异步的绘制方案是不是就可以了,这个方法实际上也是有些问题的,我们继续以图形A
和图片B
为例,此时B
的层级是在A
之上的,那么在异步绘制的时候,因为存在绘制层级顺序的关系,我们会先绘制A
再绘制B
从而保证层级,那么此时因为B
是异步的绘制,就会导致短暂的A
图形首先绘制,然后再绘制图片B
,整体感官上会导致闪烁的问题。此外我们必须要保证所有的绘制人物是呈现队列的形式依次调用,不能同时有很多的绘制任务批量调度,否则可能会导致ctx
的方法调用顺序混乱。
实际上绘制图片确实是件麻烦事,因为我们是插件化的设计,我们又必须要保证整个应用的插件通用性。通过上述两个方案的遇到的问题,我设想了新的方案,因为整体实际上还是图片绘制的调度问题,并且我们这层绘制不能仅仅由插件自己调度,我们绘制是必须要通过应用层面调度的,因此我收集插件调用后的返回值来判断当前是否有其他异步任务,如果有的话就等待所有异步任务结束之后再次触发批量绘制行为,这样通过在应用层面的调度绘制解决了问题。再往后可以考虑异步与队列的渲染方案,实际上这种方案能够有更好的绘制性能,不容易造成长任务导致用户交互卡顿的问题。
在先前的选中图形frame
中,我们都是用stroke
来实现的,然后最近我想将其真正作为外边框来绘制,然后就发现想绘制inside stroke
确实不是一件容易的事。从MDN
上阅读stroke
的文档可以得到其是以路径的中心线为基准的,也就是说stroke
是由基准分别向内外扩展的,那么问题就来了,假如我们绘制了一条线,而这条线本身是存在1px
宽度的,那么初步理解按照文档所说其本身结构应该是以这1px
本身的中心点也就是0.5px
的位置为中心点向外发散,然而其实际效果是以1px
的外边缘为基准发散,那么就会导致1px
的线在stroke
之后会多出0.5px
的宽度,这个效果可以通过lineTo(0, 100)
外加lineWith=1
来测试,可以发现其可见宽度只有0.5px
,这点可以通过再画一个1px
的Path
来对比。
那么这里的Strokes are aligned to the center of a path
可能与我理解的center of a path
并不相同,或许其只是想表达stroke
是分别向两侧绘制描边的,而并不是解释其基准位置。关于这个问题我咨询了一下,这里主要是理解有偏差,在我们使用API
绘制路径时,本身并没有设置宽度的信息,而坐标信息定义的是路径的轮廓或边界,因此我们在最开始定义的路径结构1px
是不成立的。在图形学的上下文中,路径path
通常是指一个几何形状的轮廓或线条,路径本身是数学上的抽象概念,没有宽度,只是一个由点和线段构成的轨迹,因此当我们提到描边stroke
时,指的是一个可视化过程,即在路径的周围绘制有宽度的线条。
实际上这里如果仅仅是处理frame
的问题的话,可能并没有太大的问题,然而在处理节点的时候,发现由于是使用stroke
绘制的操作节点,那么实际上其总是会超出原始宽度的,也就是上边说的描边问题,而因为超出的这0.5px
的边缘节点,使得我一直认为绘制节点的边缘与填充是没问题的,然而今天才发现这里的顺序反了,描边的内部会被填充覆盖掉,也就是说实现的border
宽度总是会被除以2
的,因此要先填充再描边才是正确的绘制方式。此外,无论是frame
节点的绘制还是类似border
的绘制,在Firefox
中inside stroke
总是会出现兼容性问题,仅有组合fill
以及使用fill
配合Path2D + clip
才能绘制正常的inside stroke
。