大家好,我是前端西瓜哥。
这次讲解钢笔工具的实现思路。
先看一下整体效果:
我正在开发的 suika 图形编辑器: https://github.com/F-star/suika 线上体验: https://blog.fstars.wang/app/suika/
钢笔工具的作用是:绘制一些复杂的图形。
这种图形叫做路径 Path,你也可以理解为多段线。
它将多条相对简单的线连接并做节点的光滑处理,最终变成一条灵活复杂的线。
像是 SVG 的 Path 的元素,单段的线有直线、圆弧、椭圆弧、二阶贝塞尔曲线、三阶段贝塞尔曲线等。
为数据标准化,以及简化用户操作,我们常常选择灵活性和表现力优秀的 三阶段贝塞尔曲线 来表达路径。
关于钢笔工具更多功能说明,可以看我的这篇钢笔工具说明书:
这里会假设你已经看过这篇文章,后面的内容不会过多讲解一些专业术语。
首先要定义好钢笔所绘制的图形:Path。
我选择 segment 的表达方式,如图。
设计的 Path 的数据结构为:
// 注意这里存的是数组
type PathData = PathItem[];
interface Segment {
point: { x: number; y: number }; // 锚点
in: { x: number; y: number }; // 入点(这里用的相对坐标,相对锚点)
out: { x: number; y: number }; // 出点(同上)
}
interface PathItem {
segs: Segment[];
closed: boolean;
}
这是一个 复杂 Path,由多个简单 Path 组成。
比如对下面 Path 图形,
它的表达大致为:
const pathData = [
{
// pathItem 0
segs: [
{
point: { x: 84.2705, y: 194.5 },
in: { x: 145.5, y: 37 },
out: { x: -145.5, y: -37 },
},
// ...
],
closed: false, // 不闭合
},
// pathItem 1
{
segs: [
{
point: { x: 251.2708, y: 66 },
in: { x: 0, y: 0 },
out: { x: 0, y: 0 },
},
// ...
],
closed: true, // 闭合
},
];
至于渲染,基本所有渲染引擎都支持 Path 的渲染,只要把数据结构转换一下就可以了。
比如,对于 SVG 可以用 Path 元素的 C 命令;对于 Canvas 2D 可以用 bezierCurveTo 方法。
另外,如果要做高级版的 Path:Figma 的矢量网格,是需要自己实现渲染器逻辑的,这也是我没选择实现它而是使用更通用的 Path 的原因。
图形编辑器有很多子模块,比如快捷键、工具的管理。
这样我们就可以通过 delete 键删除图形,将当前工具切换为绘制矩形工具以绘制矩形。
当绘制 Path 的时候,需要进入 Path 编辑器,此时我们需要 接管改写原来编辑器的一些功能。
1、临时禁用一些工具包括它们的快捷键,只开启和 Path 编辑相关的工具。
// 记录好原来的可用工具,后面退出 Path 编辑器时需要复原
this.prevToolKeys = editor.toolManager.getEnableTools();
// 只开启 Path 相关工具
editor.toolManager.setEnableHotKeyTools([
PathSelectTool.type,
DrawPathTool.type,
]);
此时其他工具无法通过任何方式进行切换,比如快捷键。这么做是防止用户误操作,不小心退出还没完成的 Path 的编辑。
另外可以考虑通过事件的方式通知 UI 层,只显示当前能用的工具。
2、禁用一些功能。
比如高亮选中图形的轮廓,悬停在某个图形上,通知图层面板高亮对应 item。
editor.sceneGraph.showSelectedGraphsOutline = false;
editor.sceneGraph.highlightLayersOnHover = false;
3、覆盖或新增一些快捷键。
比如 Esc 键,原来的效果是回到选择工具以及取消图形选中,现在要改写为取消 Path 控制点的选中状态,以及退出 Path 编辑器。
此外还有 Enter 键,注册为退出 Path 编辑器。Delete 原来是删除选中的图形,要改写为删除选中的曲线片段。等等。
因为我的快捷键管理使用的是 短路模式(匹配到一个就结束),所以额外注册一个高优先级的事件响应函数就完事了。
退出 Path 编辑器后,这些功能覆写都需要进行还原。
此外,我们还要维护选中的 Path 的控制点,所以我们声明一个 SelectedControl 类,放到 PathEditor 下。
该模块的作用是,维护已经被选中的控制点,计算 Path 上需要渲染的控制点进行渲染。
SelectedControl 记录当前 Path 上被选中的控制点:
const selected = [
// 锚点控制点,在索引值为 0 的 path 上的索引值为 1 的 seg 上
{ type: 'anchor', pathIdx: 0, segIdx: 1 }
// ...
{ type: 'anchor', pathIdx: 0, segIdx: 2 }
]
控制点除了 anchor、还有 in、out、curve。
首先我们要基于当前 Path,渲染出所有的锚点(这里用白心蓝边表示)。
被选中控制点的相邻 segment 的 handleIn 和 handleOut 控制点会被绘制。
in 和 out 到对应的锚点的连线也要绘制,这样我们才知道它们属于哪一个 Segment。
选中控制点本身会渲染为选中状态(图中的蓝心白边圆)。
被选中的控制点,可以进行类似被选中图形的操作:
更多请阅读文章开头提及的文章。
按下 Esc 键,如果有选中的控制点,清空;如果已经没有选中控制点,退出 Path 编辑器。
点击钢笔工具按钮,此时 Path 编辑器还没有激活,因为我们目前还没有创建 Path。
当我们按下鼠标,绘制第一个锚点时,会创建一个 Path。
此时开启 Path 编辑器,并将这个 Path 传过去。
设置 handleIn 和 handleOut
此时按住鼠标不放,然后拖拽,就会更新Path 控制点的 in 和 out 的位置。
默认 in 和 out 长度角度对称,按住 Alt 键会变成不对称,相互独立。按住shift强制极轴追踪(45 度的倍数)
每画完一个锚点,该锚点会被选中。
我们会 基于当前选中锚点,且为 PathItem 的一个末点,去绘制它的相邻的下一个锚点。
因此,你可能需要考虑 把选中控制点这种行为,也保持到历史记录里。
如果当前没有锚点被选中或不是末点,那就绘制一个新的 PathItem。
注意这个 PathItem 和其他 PathItem 是属于同一个复杂 Path 的。
在准备绘制下一个锚点的时候,移动鼠标,会绘制两个特殊的控制点:
表示如果你按下鼠标,新的一段曲线的形状就会是这样子的。
当光标落在 PathItem 的某一个末点上,光标进行更换,表示点下去会闭合当前 PahtItem。
类似选择工具,选择工具选中操作的是图形,而 Path 选择工具选中和操作的是 Path 上的控制点。
这里类似同样需要实现一套点选、框选、连选的逻辑。
这里不多说,基本上和选择工具大同小异,可以看这篇文章:
虽然但是,Path 的进入和退出的场景有很多种,你需要注意有没有漏掉一些。
只画了一个锚点就结束编辑了怎么办?在结束编辑后追加一个删除 Path 命令。
绘制第一个锚点时,有创建 Path 命令和修改 handleIn 和 handleOut 命令,这两个命令,撤销两次才能取消一个 segment,怎么解决?
可以通过将两个命令标记为批量执行,撤销重做时连续执行,有点类似宏命令。需要对命令管理类进行改造,供高级用法。
右侧属性面板可以显示选中控制点的位置信息,并支持通过输入框修改。
...
钢笔工具(和 Path 选择工具)是复杂工具,属于图形编辑器的核心工具,它有非常多的功能需要实现,目前我只搭了个框架而已。
它的背后其实是一个 Path 编辑器,一套不同的另一套编辑器体系,会接管改写原来图形编辑器部分能力。
我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有