线构
项目预览
整体架构

-
界面层:负责面向用户展示编辑器界面并接收操作,包括工具栏、属性面板、画布容器和空状态等。它把用户意图交给下层服务,并订阅状态更新界面,本身不处理图形算法和底层绘制。
-
图形操作层:把鼠标、触控笔和键盘输入转换成明确的编辑动作,是"用户输入"到"领域操作"的入口。
-
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 提供各层共用的基础设施。
text
src/
├── main.tsx、App.tsx 应用入口与整体组装
├── widget/ 界面组件
├── domain/ 编辑器领域逻辑与业务编排
│ ├── contract/ 定义能力和依赖契约
│ └── service/ 实现具体业务能力
├── shape/ 图形领域模型
│ ├── contract/ 图形数据与类型定义
│ ├── property/ 图形属性
│ ├── state/ 图形状态机
│ └── decorate/ 选中、悬浮等交互装饰
├── canvas/ 画布与渲染容器
├── common/ 通用基础能力与依赖注入
├── i18n/ 国际化资源
└── types/ 全局类型补充
每个 package 下面都有一个 contract 和 service目录,那么它们的关系是什么?
-
contract:定义接口、描述能力,不包含具体实现,使其他模块可以依赖稳定契约
-
service:实现 contract 内的接口
依赖注入
项目使用了依赖注入的方式来管理 service 的生命周期,依赖注入有诸多好处,比如:降低耦合、统一管理 service 的生命周期、方便替换和测试等等,这里就不一一说明了。下面将说明项目中是如何使用依赖注入的
依赖注入管理
项目使用 InversifyJS 管理 service 的创建、复用与依赖关系,src/common 是整套依赖注入机制的基础设施层。业务代码只依赖接口对应的 Symbol,不直接依赖具体实现类。
- 在
common/container.ts中创建并初始化全局 IoC 容器,并加载所有由装饰器声明的绑定
typescript
import { Container, BindingScopeEnum } from 'inversify';
import { buildProviderModule } from 'inversify-binding-decorators';
import '@/domain'; // 副作用导入会执行各实现类上的装饰器
import './service';
const container = new Container({
defaultScope: BindingScopeEnum.Singleton,
});
container.load(buildProviderModule());
export { container };
- 在
common/context.tsx中提供ContextProvider、useInject、useMultiInject和provideMultiple,方便在 React 组件中获取到
项目中如何使用依赖注入
使用依赖注入时遵循一个固定流程:先定义契约与 Token,再注册实现,最后由消费者按 Token 获取依赖。这样调用方只知道"需要什么能力",不需要知道"由哪个类创建、何时创建"。
下面以 LoggerService 为例
1. 定义契约与运行时 Token
typescript
export interface ILoggerService {
log(message: string): void;
warn(message: string): void;
error(message: string): void;
}
export const ILoggerService = Symbol('ILoggerService');
2. 注册实现
typescript
import { provide } from 'inversify-binding-decorators';
import { ILoggerService } from '../contract';
@provide(ILoggerService)
export class LoggerService implements ILoggerService {
log(message: string): void {
console.log(`[log]: ${message}`);
}
warn(message: string): void {
console.warn(`[warn]: ${message}`);
}
error(message: string): void {
console.error(`[error]: ${message}`);
}
}
3. 在另一个 service 中注入
typescript
import { inject } from 'inversify';
@provide(ICanvasInitService)
export class CanvasInitService implements ICanvasInitService {
@inject(ILoggerService)
private loggerService!: ILoggerService;
}
4. 在 React 组件中获取
tsx
const Component = () => {
const loggerService = useInject<ILoggerService>(ILoggerService);
loggerService.log('Component render');
return <demo />
}
测试时可以把同一个 Token 绑定到 Fake 或 Mock 实现,消费者代码无需修改,这也是接口与实现分离带来的直接收益。
图形渲染

每个图形都继承自 BaseShape。Shape 的核心是一个 shape.container:它负责整体的位置、旋转和层级,内部再按从下到上的顺序放置不同渲染层。
Shape 内部的渲染分层
text
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,并始终显示在最上方;状态结束后会被移除。
图形内容层的绘制顺序
typescript
graphics.clear();
drawTransparentPath(); // 透明命中区域
drawFill(); // 填充
drawStroke(); // 描边
drawText(); // 文字位于独立的上层
封闭图形会先绘制一层不可见的完整轮廓,用于稳定的点击命中;随后依次绘制填充和描边。文字是独立的 PixiText,因此不会被填充或描边覆盖。
什么时候会更新各层
-
位置、旋转变化:只更新 container 的变换。
-
大小、填充、描边变化:清空并重绘 graphics。
-
文字变化:更新 textView,并重新计算文字布局。
-
Hover、选中状态变化:添加、刷新或移除 decorate.graphics。
可以把一个 Shape 理解为:container 管整体变换,graphics 画主体,textView 画文字,decorate.graphics 画交互状态。
Shape 创建完成后,ShapeManager 只需要把整个 shape.container 添加到 Viewport,内部所有层就会一起移动、缩放和旋转。
图形交互

Shape 内部使用 状态机 + 装饰器 管理交互表现:状态机决定图形当前处于什么状态,装饰器负责把这个状态显示出来。两者都由 BaseShape 持有。
图形内部的状态机
每个 Shape 在创建时都会生成一个 StateMachine,初始状态为 Normal。项目定义了 Normal、Hover、Selected、MultiSelected、Moving、Resizing、Rotating 和 Edit 等状态。
每个状态都继承 AbsState,主要包含三部分:
-
allowNextStateTypes:当前状态允许切换到哪些状态。 -
onActivate():进入状态时执行。 -
onDeactivate():离开状态时执行。
typescript
export abstract class AbsState {
public abstract type: ShapeStateEnum;
protected shape: BaseShape;
constructor(shape: BaseShape) {
this.shape = shape;
}
public abstract onActivate(): void;
public abstract onDeactivate(): void;
public allowNextStateTypes: ShapeStateEnum[] = [];
}
BaseShape.setState() 会通过 StateFactory 创建状态对象,并缓存在 stateMap 中重复使用。状态机只处理合法的状态切换,不负责绘制边框或手柄。
图形装饰器
装饰器是图形在不同状态状态下的样式和行为,是独立于图形内容的渲染,有以下装饰器
-
HoverBorder: 绘制鼠标悬停时的高亮边框。
-
SelectedBorder: 绘制选中边框、缩放手柄、旋转手柄和连线锚点。
连线图形会用 LineSelectedBorder 替换普通的 SelectedBorder,显示端点、途经点和虚拟中点手柄。
typescript
onActivate() {
this.draw();
shape.container.addChild(this.graphics);
}
refresh() {
this.draw();
}
onDeactivate() {
shape.container.removeChild(this.graphics);
}
装饰器激活时才会加入 shape.container,因此位于图形内容和文字的上方;状态结束后会从 container 中移除,不会修改图形本身的数据。
状态与装饰器如何配合
-
Hover:
HoverState.onActivate()激活 HoverBorder,离开时移除。 -
Selected:
SelectedState.onActivate()激活 SelectedBorder,离开时移除。 -
Resizing / Rotating: 继续使用 SelectedBorder,使操作过程中边框和手柄保持可见。
-
Normal: 不显示任何 Shape 内部装饰。
图形的尺寸、描边等属性变化后,BaseShape.refreshDecorates() 会重新绘制装饰器;Viewport 缩放变化时,装饰器也会刷新,并用当前缩放比例调整线宽和手柄半径,使它们在屏幕上的视觉大小保持稳定。
状态机负责何时显示,装饰器负责显示什么。 状态切换只调用装饰器的激活、刷新和移除,不把交互样式混入图形主体的绘制逻辑。
后续
接下来会继续介绍图形交互、图形渲染相关的。