图形编辑器开发:绘制图形工具

大家好,我是前端西瓜哥。

今天来介绍如何实现图形绘制工具,实现绘制任意的图形。

编辑器 github 地址:

github.com/F-star/suik...

线上体验:

blog.fstars.wang/app/suika/

我之前讲过如何实现工具类管理类的:

图形编辑器:工具管理和切换

对应的工具类的实现会围绕用户的 按下鼠标、拖拽、释放 这 3 个行为,图形绘制工具同样如此。

整体框架:

typescript 复制代码
// 绘制图形工具类(这里用了抽象类,后面会说为什么)
abstract class DrawGraphTool {
  // 工具被激活
  active() {
    // 通常是设置光标,或是绑定一些事件,比如键盘事件
  }
  // 工具失活
  inactive() {
    // 通常是解绑一些事件
  }
  
  // 鼠标按下
  start() { /* TODO */ }
  // 鼠标拖拽
  drag() { /* TODO */ }
  // 鼠标释放
  end() { /* TODO */ }
}

类似 React / Vue 的生命周期 hook。

模板模式

图形有很多种,矩形、椭圆、三角形、五角星等等。每个图形都实现一遍未免有点繁琐。

西瓜哥我一开始是分别去实现绘制矩形和椭圆的,然后发现有很多相同的逻辑。当又要加一个新的图形时,又要复制粘贴,然后修改少量的不一样的地方,这不利于代码维护。

为解决这个问题,我们要实现一个 绘制图形基类,将共用逻辑放到里面,不同的部分则交给子类去实现

这个在设计模式上叫做 模板模式

所谓模板模式,就是在方法中定义一个 "算法" 骨架,继承的子类在不改变算法整体结构的情况下,重写其中某些步骤(有些步骤有默认实现,可不重写)

模板模式的具体实现,就是用 抽象类(abstract class) 去实现这个基类。

抽象类是一种不能被实例化的特殊类,继承的子类才能实例化。

抽象类的方法可以是普通方法,也可以是只定义了方法类型签名的抽象方法。

子类继承抽象类时,必须提供抽象类的抽象方法的具体实现。

TypeScript 支持抽象类。下面是一个例子。

typescript 复制代码
// 抽象类
abstract class AbstractClass {
  say() {
    if (this.shoudISaySomething()) {
      console.log('前端西瓜哥')
    }
  }
  // 抽象方法(不能用 private,因为子类要重写它)
  protected abstract shoudISaySomething(): boolean
}

class A extends AbstractClass {
  shoudISaySomething() {
    // ...假设这里一堆判断
    return true
  }
}

子类不实现抽象方法的话,TS 编译会报错:

如果你用 JavaScript,虽然不能做编译时的检验,但还可以做运行时的检测。

将需要子类继承实现的方法,加入抛出错误的实现。这样子类如果没实现,就会通过原型链的方式,执行基类的方法,然后报错提示给开发者。

javascript 复制代码
class AbstractClass {
  say() {
    if (this.shoudISaySomething()) {
      console.log('前端西瓜哥')
    }
  }
  shoudISaySomething() {
    throw new Error('请实现 shoudISaySomething 方法')
  }
}

class A extends AbstractClass {
  shoudISaySomething() {
    // ...假设这里一堆逻辑
    return true
  }
}

图形绘制工具的实现

我们回到绘制图形的业务逻辑。

我们在鼠标按下时确定起始坐标,拖拽时调整终点坐标,鼠标释放确认终点坐标。

这里产生了一个矩形框,得到 x、y、width、height,通过它们可以确定了一个图形的位置和大小。

当要加一个新的图形时,只要它能够通过 x、y、width、height 这几个属性确定绘制效果,那就可以使用这个基类。

如果这个图形还有其他属性,我们可以在绘制后通过其他方式(比如控制点或者面板修改值)去修改。

鼠标按下

首先是鼠标按下的逻辑。逻辑很少,主要是记录起始点。

kotlin 复制代码
abstract class DrawGraphTool {
  commandDesc = 'Add Graph'; // 历史记录的命令描述
  protected drawingGraph: Graph | null = null; // 被绘制的图形对象
  
  
  start(e: PointerEvent) {
    // 这里将光标的视口坐标转成场景坐标
    this.startPoint = this.editor.getSceneCursorXY(e);
    
    // 重置一些状态
    this.drawingGraph = null;
  }
}

鼠标拖拽

拖拽的时候,会判断 this.drawingGraph 是否为 null。

如果是,就会创建一个新的图形对象。如果不是,那就更新 this.drawingGraph 的 x、y、 width、height 属性。

kotlin 复制代码
abstract class DrawGraphTool {
  private lastDragPoint!: IPoint;
  
  drag(e: PointerEvent) {
    // 记录终点坐标
    this.lastDragPoint = this.editor.getSceneCursorXY(e);
    this.updateRect();
  }
  
  // 更新矩形选框,并对图形对象进行操作
  private updateRect() {
    const { x, y } = this.lastDragPoint;
    const sceneGraph = this.editor.sceneGraph;
    const { x: startX, y: startY } = this.startPoint;

    const width = x - startX; // 这个可能是负数,还没做标准化
    const height = y - startY; // 同上

    const rect = {
      x: startX,
      y: startY,
      width,
      height,
    };

    // 按住shift键,通过算法把矩形变成方形。
    if (this.editor.hostEventManager.isShiftPressing) {
      this.adjustSizeWhenShiftPressing(rect);
    }

    if (this.drawingGraph) {
      // (1)更新图形逻辑
      this.updateGraph(rect);
    } else {
      // (2)创建图形逻辑
      const element = this.createGraph(rect)!;
      sceneGraph.addItems([element]);

      this.drawingGraph = element;
    }
    // 设置选中对象,并渲染
    this.editor.selectedElements.setItems([this.drawingGraph]);
    sceneGraph.render();
  }
}

创建图形

创建图形对象的方法是 createGraph(),要返回一个图形对象,保存到 this.drawingGraph

这个图形对象需要子类来提供。所以写成抽象方法:

arduino 复制代码
protected abstract createGraph(rect: IRect, noMove?: boolean): Graph | null;

我们的矩形绘制工具,实现如下。

arduino 复制代码
export class DrawRectTool extends DrawGraphTool implements ITool {
 // ...
  
  // 这里提供实现创建图形对象
  protected createGraph(rect: IRect) {
    rect = normalizeRect(rect);
    return new Rect({
      ...rect,
      fill: [cloneDeep(this.editor.setting.get('firstFill'))],
    });
  }
}

这里用 normalizeRect 对 rect 对象做了标准化,原来 width 和 height 可能为负数,标准化就是改变 x、y,并让 width 和 height 变回正数,变成一个常规的 rect 对象。

这样我们拿到了图形对象通用属性:x、y、width、height,然后这里再补上了一个默认的填充色。

如果要实现绘制直线,就不要提供填充色,而是要补一个默认描边。

更新图形

更新图形通常就是更新一下图形的 x、y、width、height 属性,所以基类会提供一个默认实现。

ini 复制代码
/**
 * 这个是通用逻辑,直接更新 x、y、width、height
 */
protected updateGraph(rect: IRect) {
  // 对矩形标准化
  rect = normalizeRect(rect);

  const drawingShape = this.drawingGraph!;
  drawingShape.x = rect.x;
  drawingShape.y = rect.y;
  drawingShape.width = rect.width;
  drawingShape.height = rect.height;
}

当然有些图形并不是这样的逻辑,那子类就需要重写 updateGraph 方法。

比如绘制直线就比较特殊,它更新的是 width 和 rotation,height 则永远是 0,需要另写一个算法去实现转换。

Shift 模式

这里有个比较特别的效果,就是按住 Shift,会让 图形的宽高比保持一比一

绘制正方形:

绘制圆形:

实现就是找 width 和 height 绝对值大的那一个,然后符号保持不变,两者的绝对值都变成这个最大值。

arduino 复制代码
protected adjustSizeWhenShiftPressing(rect: IRect) {
  // pressing Shift to draw a square
  const { width, height } = rect;
  
  const size = Math.max(Math.abs(width), Math.abs(height));
  // Math.sign() 方法可能会返回 0,所以要兜底为 1
  rect.height = (Math.sign(height) || 1) * size;
  rect.width = (Math.sign(width) || 1) * size;
}

子类如果比较特殊(没错说的就是你,直线工具),可重写该方法。

顺带一提,还有一种 Alt 模式,会将起始点作为图形的中心点进行绘制,这个我还没去实现。

鼠标释放

鼠标释放时,主要逻辑是将新的状态保持到历史记录中。

kotlin 复制代码
end(e: PointerEvent) {
  if (this.drawingGraph) {
    // 记录新的状态
    this.editor.commandManager.pushCommand(
      new AddShapeCommand(this.commandDesc, this.editor, [this.drawingGraph]),
    );
  }
}

结尾

模板模式的优点是复用和扩展。相同的主体框架逻辑不变,暴露几个方法让子类实现,有些是必须实现,有些是可实现可不实现(不实现用默认算法),对我们实现一种通用的绘制图形工具很有帮助。

实现了这个图形绘制基类后,我们理论上就可以绘制任何图形了,甚至用户自定义的图形,只要这些图形对象使用 x、y、 width、height。

我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。


相关阅读,

图形编辑器开发:最基础但却复杂的选择工具

图形编辑器:工具管理和切换

图形编辑器:底层设计

图形编辑器:对齐功能的实现

图形编辑器:历史记录设计

图形编辑器:防误操作之拖拽阻塞

图形编辑器:修改图形x、y、width、height、rotation

相关推荐
weixin_382395233 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__3 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华5 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni8 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学9 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端