实现一个图形编辑器

线构

项目预览

整体架构

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

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

    • 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 下面都有一个 contractservice目录,那么它们的关系是什么?

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

  • service:实现 contract 内的接口

依赖注入

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

依赖注入管理

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

  1. 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 };
  1. common/context.tsx中提供 ContextProvideruseInjectuseMultiInjectprovideMultiple,方便在 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 缩放变化时,装饰器也会刷新,并用当前缩放比例调整线宽和手柄半径,使它们在屏幕上的视觉大小保持稳定。

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

后续

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

相关推荐
haishikeji696_1 小时前
飞控管理平台源码|无人机智慧巡查系统私有化部署 低空一网统管巡检平台选型指南
架构·无人机·低空经济·无人机巡检·无人机管理系统·一网统飞·飞控管理平台
安易算力1 小时前
昇腾生态开发深度实践:CANN算子库架构解析与MindSpore模型优化
网络·容器·架构·kubernetes·vllm
Huanzhi_Lin1 小时前
从0搭建ECS:跨品类评估
架构·游戏开发·ecs·ecs跨品类评估
Nayana1 小时前
《Web 到 HarmonyOS》-- 嵌入 Web 与桥接基础
前端
一拳不是超人1 小时前
之前用 AI 两小时写的塔罗网站,我真把它做上线了,然后呢?
前端·人工智能·程序员
周先生FullStack2 小时前
Mac + 容器本地 MySQL/Redis 环境搭建与网络原理实战
java·spring boot·微服务·容器·架构·个人开发
flash俊杰2 小时前
ASR 转写与字幕时间轴:强制对齐、CPS 约束与 SRT 工程化
前端
前端炒粉2 小时前
AI工具面经
前端