实现一个图形编辑器

文章来源声明: 原文作者:cAuth; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686803938519957514; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把分层边界、依赖注入和 Shape 渲染分层讲得很透,适合想自建 Canvas 图形编辑器的前端团队参考,状态机加装饰器的交互设计尤其值得借鉴。

线构 --

项目预览

整体架构

image.png

  • 界面层:负责面向用户展示编辑器界面并接收操作,包括工具栏、属性面板、画布容器和空状态等。它把用户意图交给下层服务,并订阅状态更新界面,本身不处理图形算法和底层绘制。

  • 图形操作层:把鼠标、触控笔和键盘输入转换成明确的编辑动作,是“用户输入”到“领域操作”的入口。

    • EventManager:监听 pointer 事件,生成包含视口坐标、屏幕坐标和缩放比例的事件载荷;根据当前工具选择交互模式,并按处理器链完成悬浮、选择、框选、移动、缩放、旋转、创建、连线和文字编辑等操作。
    • Action:将创建、删除、属性修改等变更封装为可执行动作,由 ActionManager 分派执行,并通过 ActionLog 支持撤销、重做;前后置拦截器用于维护连线等关联关系。
    • 快捷键:统一监听和匹配键盘组合键,将删除、全选、复制、撤销、重做及工具切换等命令转发给 Action、SelectService 或工具状态。
  • 图形管理层:维护编辑器运行时的图形集合、选择状态和空间索引,为上层交互提供统一的数据访问与命中查询能力。

    • ShapeManager:作为运行时图形仓库,负责图形的增删查、显示顺序、加入 Stage、命中检测和空间索引同步。
    • QuadTreeManager:维护图形包围盒的四叉树空间索引,先快速筛出点选或区域查询的候选图形,减少逐个遍历全部图形的开销。
  • 图形层:定义图形领域模型,封装几何、属性、状态、命中检测、序列化及交互装饰,使上层不必关心具体绘制细节。

    • Shape:BaseShape 提供图形 ID、容器、属性、装饰器、状态机、命中检测和序列化等公共能力;TextEditableShape 增加文字布局与编辑能力;ClosedShape 复用封闭图形的填充、描边和文字绘制流程;Text、Circle、Rectangle 等具体图形只需实现自身几何和绘制规则。
    • Property:以属性对象驱动重绘。BaseProperty 管理位置、尺寸和旋转;FillProperty 管理填充颜色、透明度与手绘样式;StrokeProperty 管理描边颜色、宽度、透明度与线型。
    • Decorate:绘制不属于图形内容本身的交互反馈,并随图形状态和缩放比例更新。HoverBorder 显示悬浮轮廓;SelectedBorder 显示选框、缩放手柄、旋转手柄和连线锚点;LineSelectedBorder 显示线的端点、途经点及虚拟中点手柄。
  • 渲染层:负责把图形模型最终呈现在屏幕上,并提供画布场景、视口变换和具体绘制能力。

    • Stage:持有 PixiJS Application 和根场景,管理渲染生命周期,并将图形容器加入场景。
    • Viewport:作为所有图形的父容器,维护画布世界坐标系,处理平移、缩放、适配视图及坐标转换。
    • PixiJS:提供 Graphics、Container、Text 等场景节点及高性能渲染能力。
    • RoughJS:生成带随机种子的手绘风格路径,用于手绘填充、描边和相关视觉效果。
    • React:渲染画布外的 DOM 界面,并负责组件生命周期、状态订阅以及界面层与领域服务的连接;具体图形仍由 PixiJS 绘制。

目录结构

每个目录后续会做成一个子 package

前端代码主要放在 src 下,并按照职责进行划分。整体上可以理解为:widget/ui 负责界面,domain 负责业务逻辑行为,shape 负责图形模型,canvas 负责底层画布,common 提供各层共用的基础设施。

src/
├── main.tsx、App.tsx   应用入口与整体组装
├── widget/             界面组件
├── domain/             编辑器领域逻辑与业务编排
│   ├── contract/       定义能力和依赖契约
│   └── service/        实现具体业务能力
├── shape/              图形领域模型
│   ├── contract/       图形数据与类型定义
│   ├── property/       图形属性
│   ├── state/          图形状态机
│   └── decorate/       选中、悬浮等交互装饰
├── canvas/             画布与渲染容器
├── common/             通用基础能力与依赖注入
├── i18n/               国际化资源
└── types/              全局类型补充

每个 package 下面都有一个 contractservice目录,那么它们的关系是什么?

  • contract:定义接口、描述能力,不包含具体实现,使其他模块可以依赖稳定契约
  • service:实现 contract 内的接口

依赖注入

项目使用了依赖注入的方式来管理 service 的生命周期,依赖注入有诸多好处,比如:降低耦合、统一管理 service 的生命周期、方便替换和测试等等,这里就不一一说明了。下面将说明项目中是如何使用依赖注入的

依赖注入管理

项目使用 InversifyJS 管理 service 的创建、复用与依赖关系,src/common 是整套依赖注入机制的基础设施层。业务代码只依赖接口对应的 Symbol,不直接依赖具体实现类。

  1. common/container.ts中创建并初始化全局 IoC 容器,并加载所有由装饰器声明的绑定
<span>import</span> { <span>Container</span>, <span>BindingScopeEnum</span> } <span>from</span> <span>'inversify'</span>;
<span>import</span> { buildProviderModule } <span>from</span> <span>'inversify-binding-decorators'</span>;
<span>import</span> <span>'@/domain'</span>; <span>// 副作用导入会执行各实现类上的装饰器</span>
<span>import</span> <span>'./service'</span>;

<span>const</span> container = <span>new</span> <span>Container</span>({
  <span>defaultScope</span>: <span>BindingScopeEnum</span>.<span>Singleton</span>,
});

container.<span>load</span>(<span>buildProviderModule</span>());

<span>export</span> { container };

  1. common/context.tsx中提供 ContextProvideruseInjectuseMultiInjectprovideMultiple,方便在 React 组件中获取到

项目中如何使用依赖注入

使用依赖注入时遵循一个固定流程:先定义契约与 Token,再注册实现,最后由消费者按 Token 获取依赖。这样调用方只知道“需要什么能力”,不需要知道“由哪个类创建、何时创建”。

下面以 LoggerService 为例

1. 定义契约与运行时 Token

<span>export</span> <span>interface</span> <span>ILoggerService</span> {
  <span>log</span>(<span>message</span>: <span>string</span>): <span>void</span>;
  <span>warn</span>(<span>message</span>: <span>string</span>): <span>void</span>;
  <span>error</span>(<span>message</span>: <span>string</span>): <span>void</span>;
}

<span>export</span> <span>const</span> <span>ILoggerService</span> = <span>Symbol</span>(<span>'ILoggerService'</span>);

2. 注册实现

<span>import</span> { provide } <span>from</span> <span>'inversify-binding-decorators'</span>;
<span>import</span> { <span>ILoggerService</span> } <span>from</span> <span>'../contract'</span>;

<span>@provide</span>(<span>ILoggerService</span>)
<span>export</span> <span>class</span> <span>LoggerService</span> <span>implements</span> <span>ILoggerService</span> {
  <span>log</span>(<span>message</span>: <span>string</span>): <span>void</span> {
    <span>console</span>.<span>log</span>(<span>`[log]: <span>${message}</span>`</span>);
  }

  <span>warn</span>(<span>message</span>: <span>string</span>): <span>void</span> {
    <span>console</span>.<span>warn</span>(<span>`[warn]: <span>${message}</span>`</span>);
  }

  <span>error</span>(<span>message</span>: <span>string</span>): <span>void</span> {
    <span>console</span>.<span>error</span>(<span>`[error]: <span>${message}</span>`</span>);
  }
}

3. 在另一个 service 中注入

<span>import</span> { inject } <span>from</span> <span>'inversify'</span>;

<span>@provide</span>(<span>ICanvasInitService</span>)
<span>export</span> <span>class</span> <span>CanvasInitService</span> <span>implements</span> <span>ICanvasInitService</span> {
  <span>@inject</span>(<span>ILoggerService</span>)
  <span>private</span> loggerService!: <span>ILoggerService</span>;
}

4. 在 React 组件中获取

<span>const</span> <span>Component</span> = (<span></span>) => {
  <span>const</span> loggerService = useInject<<span>ILoggerService</span>>(<span>ILoggerService</span>);
  loggerService.<span>log</span>(<span>'Component render'</span>);
  
  <span>return</span> <span><span><<span>demo</span> /></span></span>
}

测试时可以把同一个 Token 绑定到 Fake 或 Mock 实现,消费者代码无需修改,这也是接口与实现分离带来的直接收益。

图形渲染

image.png

每个图形都继承自 BaseShape。Shape 的核心是一个 shape.container:它负责整体的位置、旋转和层级,内部再按从下到上的顺序放置不同渲染层。

Shape 内部的渲染分层

shape.container                  // 整体变换层
├─ graphics                     // 图形内容层
├─ textBackgroundView           // 文字衬底层
├─ textView                     // 文字层
└─ decorate.graphics            // 交互装饰层

1. 整体变换层:container

BaseProperty 把 x、y、width、height 和 rotation 应用到 container。移动和旋转只修改 container,不需要重新绘制内部内容;pivot 位于图形中心,因此图形会围绕中心旋转。

2. 图形内容层:graphics

负责图形本身的几何形状、填充和描边。例如矩形调用 drawRect(),圆形调用 drawCircle()。这一层始终位于最底部。

3. 文字层:textBackgroundView + textView

textView 显示图形中的文字,放在 graphics 上方;使用手绘填充时,textBackgroundView 会在文字下方增加干净的衬底,避免排线影响阅读。

4. 交互装饰层:decorate.graphics

Hover 边框、选中框、缩放手柄、旋转手柄和连线锚点都属于装饰层。它们不属于图形内容,只在状态机进入 Hover 或 Selected 状态时添加到 container,并始终显示在最上方;状态结束后会被移除。

图形内容层的绘制顺序

graphics.<span>clear</span>();

<span>drawTransparentPath</span>(); <span>// 透明命中区域</span>
<span>drawFill</span>();            <span>// 填充</span>
<span>drawStroke</span>();          <span>// 描边</span>
<span>drawText</span>();            <span>// 文字位于独立的上层</span>

封闭图形会先绘制一层不可见的完整轮廓,用于稳定的点击命中;随后依次绘制填充和描边。文字是独立的 PixiText,因此不会被填充或描边覆盖。

什么时候会更新各层

  • 位置、旋转变化:只更新 container 的变换。
  • 大小、填充、描边变化:清空并重绘 graphics。
  • 文字变化:更新 textView,并重新计算文字布局。
  • Hover、选中状态变化:添加、刷新或移除 decorate.graphics。

可以把一个 Shape 理解为:container 管整体变换,graphics 画主体,textView 画文字,decorate.graphics 画交互状态。

Shape 创建完成后,ShapeManager 只需要把整个 shape.container 添加到 Viewport,内部所有层就会一起移动、缩放和旋转。

图形交互

image.png

Shape 内部使用 状态机 + 装饰器 管理交互表现:状态机决定图形当前处于什么状态,装饰器负责把这个状态显示出来。两者都由 BaseShape 持有。

图形内部的状态机

每个 Shape 在创建时都会生成一个 StateMachine,初始状态为 Normal。项目定义了 Normal、Hover、Selected、MultiSelected、Moving、Resizing、Rotating 和 Edit 等状态。

每个状态都继承 AbsState,主要包含三部分:

  • allowNextStateTypes:当前状态允许切换到哪些状态。
  • onActivate():进入状态时执行。
  • onDeactivate():离开状态时执行。
<span>export</span> <span>abstract</span> <span>class</span> <span>AbsState</span> {
  <span>public</span> <span>abstract</span> <span>type</span>: <span>ShapeStateEnum</span>;

  <span>protected</span> <span>shape</span>: <span>BaseShape</span>;

  <span>constructor</span>(<span>shape: BaseShape</span>) {
    <span>this</span>.<span>shape</span> = shape;
  }

  <span>public</span> <span>abstract</span> <span>onActivate</span>(): <span>void</span>;

  <span>public</span> <span>abstract</span> <span>onDeactivate</span>(): <span>void</span>;

  <span>public</span> <span>allowNextStateTypes</span>: <span>ShapeStateEnum</span>[] = [];
}

BaseShape.setState() 会通过 StateFactory 创建状态对象,并缓存在 stateMap 中重复使用。状态机只处理合法的状态切换,不负责绘制边框或手柄。

图形装饰器

装饰器是图形在不同状态状态下的样式和行为,是独立于图形内容的渲染,有以下装饰器

  • HoverBorder: 绘制鼠标悬停时的高亮边框。
  • SelectedBorder: 绘制选中边框、缩放手柄、旋转手柄和连线锚点。

连线图形会用 LineSelectedBorder 替换普通的 SelectedBorder,显示端点、途经点和虚拟中点手柄。

<span>onActivate</span>(<span></span>) {
  <span>this</span>.<span>draw</span>();
  shape.<span>container</span>.<span>addChild</span>(<span>this</span>.<span>graphics</span>);
}

<span>refresh</span>(<span></span>) {
  <span>this</span>.<span>draw</span>();
}

<span>onDeactivate</span>(<span></span>) {
  shape.<span>container</span>.<span>removeChild</span>(<span>this</span>.<span>graphics</span>);
}

装饰器激活时才会加入 shape.container,因此位于图形内容和文字的上方;状态结束后会从 container 中移除,不会修改图形本身的数据。

状态与装饰器如何配合

  • Hover:HoverState.onActivate() 激活 HoverBorder,离开时移除。
  • Selected:SelectedState.onActivate() 激活 SelectedBorder,离开时移除。
  • Resizing / Rotating: 继续使用 SelectedBorder,使操作过程中边框和手柄保持可见。
  • Normal: 不显示任何 Shape 内部装饰。

图形的尺寸、描边等属性变化后,BaseShape.refreshDecorates() 会重新绘制装饰器;Viewport 缩放变化时,装饰器也会刷新,并用当前缩放比例调整线宽和手柄半径,使它们在屏幕上的视觉大小保持稳定。

状态机负责何时显示,装饰器负责显示什么。 状态切换只调用装饰器的激活、刷新和移除,不把交互样式混入图形主体的绘制逻辑。

后续

接下来会继续介绍图形交互、图形渲染相关的。