【canvas学习笔记】1 - 如何用canvas实现一个图形编辑器

785 阅读5分钟

前言

本项目很多算法与实现都参考了 @前端西瓜哥 的文章,非常推荐对图形学感兴趣的同学去关注他的公众号。在此也是贴上他的掘金主页

项目简介

先看看目前实现的效果

image.png

github地址: github.com/kkagura/sto…

在线体验: kkagura.github.io/

目前这个项目还比较简陋,只实现了几种基本图形与连线,在工具栏交互里也只是实现了撤回、重做、放大、缩小、删除、保存等几个基本功能。 后续的计划里是优先实现文字功能,然后就是图形的属性面板,再就是吸附对齐等易用性相关的功能。

项目架构

image.png 项目结构上采用了monorepo的方式,主要分为5个包。

core

core包是负责存放编辑器最核心的模块,例如Editor、Box、Model、Command等。

geo

geo包中则是实现了常用的几何算法,类似于矩阵,还有对Point、Rect的操作等。

shared

shared包是公用的工具的集合,类似于EventEmitter、对dom的快捷操作、对Array、Object的快捷操作等。

ui

ui包是负责页面上的ui组件,例如模型库、工具栏等,目前暂时只提供了vue的版本,以后有空的话会考虑实现react版本。

playground

playground是我开发时的测试项目。

其中core、geo、shared和ui这四个包都会发布到npm仓库,在项目下试用时可以参考playground中的App.vue:

<template>
  <div class="editor-page">
    <div class="editor-header">
      <Toolbar :commands="defaultCommands"></Toolbar>
    </div>
    <div class="editor-content">
      <Library :group-list="library"></Library>
      <div class="editor-box">
        <Editor @ready="onReady"></Editor>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Editor, Toolbar, createStomStore, getDefaultCommands, Library, getDefaultLibrary } from '@stom/ui/vue';
import { type Command, type Editor as IEditor } from '@stom/core';
import { markRaw, shallowRef } from 'vue';
const defaultCommands = shallowRef<Command[]>([]);

const store = createStomStore();

const library = shallowRef(getDefaultLibrary());
store.register(library.value);

const onReady = (editor: IEditor) => {
  store.setEditor(editor);
  const commands = getDefaultCommands(editor);
  defaultCommands.value = markRaw(commands);
};
</script>
<style lang="postcss">
.editor-page {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  .editor-content {
    flex: 1;
    overflow: hidden;
    display: flex;
    .editor-box {
      flex: 1;
      height: 100%;
      overflow: hidden;
    }
  }
}
</style>

核心模块介绍

Editor

编辑的主模块。这个类接收一个参数: contaner,即容器dom,在实例化的时候,Editor会在这个容器下创建三个canvas元素,分别为topCanvas、mainCavans、rootCanvas。

其中topCanvas是用来绘制交互相关的效果,例如框选框、拉伸的控制器以及吸附对齐的辅助线(目前暂未实现)。

mainCanvas是用来绘制图形,也就是代码中所有的Model类,这一层canvas可能是重绘最为频繁的,为了性能考虑,目前是采用了脏矩形检测的方式进行局部重绘。当拖拽画布、缩放画布时,会重绘视图窗口内的所有图形;而如果只有个别图形的样式变更时,则会检测与之相连的所有图形并生成一个脏矩形,重绘时就只会重新渲染脏矩形内的图形。

rootCanvas则负责绘制一些不经常变更的元素,例如背景图、背景色以及画布网格等,这部分内容通常只在拖拽、缩放画布时才会重绘。

Box

Box模块是管理编辑器里所有的Model,对Model的增、删操作都是在Box中实现。但是Box并不直接储存图形Model,这里涉及到另一个模块:Layer。

Layer

Layer模块是用来实现图层效果的,每一个Model都被存放在其所属的Layer中,Editor并不是按照Model被添加到Box里的先后顺序来绘制图形,而是会按照Layer的顺序绘制,这样就可以方便的实现图层效果。

ActionManager

ActionManager负责实现撤回与重做的功能。

编辑器中的所有操作都被称为Action,而Action的类型定义如下:

export interface Action {
  undo(): void;
  redo(): void;
}

即一个包含undo、redo方法的对象。其中调用undo为撤回这个操作,调用redo为重做这个操作。

ActionManager在内部维护了一个栈和一个指针。每当要撤回操作时,会执行当前指针在栈中对应的Action.undo方法,执行完毕后将指针-1,而当要重做时,则是先将指针+1,然后再执行指针对应的Action.redo方法。

当有新的操作发生时,会将当前指针以后的Action全部删除,然后再将最新的Action入栈并将指针+1。

Model

所有图形的基类,包含有一个图形最基本的属性和方法:

  1. rect属性:图形的位置与大小。
  2. attrs属性:图形的样式。
  3. transform属性:记录图形的形变:如旋转等。
  4. getRect方法:获取图形最原始的位置与大小。
  5. getBoundingRect方法:获取图形形变后的最小包围矩形。
  6. getRenderRect方法:获取图形在渲染时所需要的大小,用来计算脏矩形。

SelectionManager

负责实现图形的点选、反选等逻辑,同时也负责实现所有选中图形的包围盒样式。

EventManager

事件管理器,绑定编辑器的事件,并且对于基础的事件交互如:框选、拖拽画布、拖拽图形、删除选中图形等,都是直接在这个类中实现。而对于拉伸图形、旋转图形、连线等特殊交互,则是由EventManager交给对应的Control处理。

Control

交互控制器,当EventManager触发了特殊事件后,会由Control负责实现交互的具体行为。

通常依附于某个宿主存在,例如拉伸(ResizeControl)、旋转(RotateControl)是依附于SelectionManager存在,而连线端口(LinkControl)是依附于Model存在。

结尾

由于篇幅优先,就先只讲这么多,在后续的文章中会详细介绍每一个模块的具体代码实现。