服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

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

日期: 来源:前端西瓜哥收集编辑:西瓜

大家好,我是前端西瓜哥。今天我们看看对于一款图形编辑器,应该怎么去实现工具,比如绘制矩形、选中工具,以及如何去管理它们的。

项目地址,欢迎 star:

https://github.com/F-star/suika

线上体验:

https://blog.fstars.wang/app/suika/

一款编辑器,有两个很重要的方面,一个是性能,另一个是架构。

因为不知道用户会在画布上画上多少图形,所以需要在渲染引擎上下很大的功夫,去提高绘制的性能。性能决定了编辑器的上限,这也是为什么很多编辑器选择了 Canvas 作为绘制方案。

另一个则是架构,编辑器很复杂,即便是看上去很简单编辑器。因为里面的模块非常多,比如工具管理模块、缩放管理、历史记录、图形树维护、辅助线、标尺、设置、视口管理、热键、光标维护等等。如果模块化不够好,就会导致代码扩展性差,加功能会非常痛苦。

今天西瓜哥谈谈如何设计管理工具类,管理不同的工具。

工具类

工具的交互,通常会集中于用户的鼠标操作

比如绘制矩形,按下鼠标,会确定矩形的 x、y 值,然后拖拽鼠标,调整矩形的宽高,最后放开鼠标,矩形的形状就确认好了,并将这个绘制矩形的操作记录到历史操作中。如下图:

所以,工具类(Tool)的设计为:

export interface ITool {
  type: string; // 工具类
  active: () => void; // 切换为当前工具时调用
  inactive: () => void; // 切换为其他工具时调用
  start: (event: PointerEvent) => void; // 鼠标按下
  drag: (event: PointerEvent) => void; // 拖拽
  end: (event: PointerEvent) => void; // 鼠标释放
  moveExcludeDrag: (event: PointerEvent) => void; // 拖拽之外的鼠标移动
}

class DrawRectTool implements ITool {
  // ...
}

有点像我们 Rect 和 Vue 中的组件的概念。这是因为工具类本质也是 在生命周期内触发一些钩子(hook),拿到一些信息。

type 表示工具名称,是一个标识符,切换工具时会用到。

active 方法会在切换为当前工具时调用,通常会做的事情有:

  1. 设置光标样式;
  2. 设置一些监听器,比如绘制矩形监听 shift 键是否按下,如果按下,就绘制方形;

inactive 会在切换为其他工具时调用,通常就是将光标设置为默认值,取消监听器。

start 是鼠标按下事件,此时要记录一些初始状态,后面的事件需要基于这个初始状态进行计算。这里其实我没用鼠标事件,而是用了 pointer 指针事件,一种适用范围更广的事件,除了鼠标事件,还支持触控笔和触摸屏幕等场景。因为大家习惯鼠标事件,后面我都用鼠标事件来描述。

drag 就是鼠标拖拽事件。end 是鼠标释放事件。

最后是比较特殊的 moveExcludeDrag,代表除了拖拽场景的鼠标移动,比如选择工具,悬停在一个图形上,我们就可以用这个事件来判断是哪个图形被选中,对它进行高亮。

这就是最基本的工具类,在此上我们可以进行进一步地封装,比如更改光标样式,我们可以配个 normalCursor、dragCursor 属性,让调用者帮我们统一设置光标样式。

这里的调用者就是工具管理类。

工具管理类

工具管理类支持的能力:

  1. 维护映射表,用 type 映射到对应工具实例;
  2. 使用 setTool 方法切换工具,会根据传入的字符串在映射表中找到对应工具实例,然后调用旧的工具的 inactive 方法,再调用新工具的 active 方法,然后设置 this.currentTool 为新工具实例;
  3. 支持事件订阅,监听工具的切换,提供给 UI 层去监听。比如我们用快捷键切换工具时,UI 层就能通过监听获得最新工具标识符,将对应按钮设置为激活状态;
  4. 然后是给 DOM 元素挂载监听器,canvas 上挂载鼠标按下事件,然后是特殊的,给 window 挂载鼠标移动和鼠标。为什么不给 canvas 挂载这些事件,这是因为我们可能会在拖拽时将鼠标移出 canvas 甚至浏览器界面然后释放,会导致拖拽、释放事件没能触发。监听后,就会在何时的时机调用工具类的 start、drag、end 等方法。

ToolManager 实现如下:

class ToolManager {
  toolMap = new Map<string, ITool>();
  currentTool: ITool | null = null;
  eventEmitter: EventEmitter;
  _unbindEvent: () => void;

  constructor(private editor: Editor) {
    this.eventEmitter = new EventEmitter(); // 模仿 nodejs 的简易版 EventEmitter
    // 绑定 tool
    this.toolMap.set(DrawRectTool.type, new DrawRectTool(editor));
    this.toolMap.set(DrawEllipseTool.type, new DrawEllipseTool(editor));
    this.toolMap.set(SelectTool.type, new SelectTool(editor));
    this.toolMap.set(DragCanvasTool.type, new DragCanvasTool(editor));

    this.setTool(DrawRectTool.type);

    this._unbindEvent = this.bindEvent();
  }
  getToolName() {
    return this.currentTool?.type;
  }
  bindEvent() {
    let isPressing = false;

    const handleDown = (e: PointerEvent) => {
      if (e.button !== 0) { // 必须是鼠标左键
        return;
      }
      if (!this.currentTool) {
        throw new Error('未设置当前使用工具');
      }
      isPressing = true;
      this.currentTool.start(e);
    };
    const handleMove = (e: PointerEvent) => {
      if (!this.currentTool) {
        throw new Error('未设置当前使用工具');
      }
      if (isPressing) {
        this.editor.hostEventManager.disableDragBySpace();
        this.currentTool.drag(e);
      } else {
        this.currentTool.moveExcludeDrag(e);
      }
    };
    const handleUp = (e: PointerEvent) => {
      if (e.button !== 0) { // 必须是鼠标左键
        return;
      }
      if (!this.currentTool) {
        throw new Error('未设置当前使用工具');
      }
      if (isPressing) {
        this.editor.hostEventManager.enableDragBySpace();
        isPressing = false;
        this.currentTool.end(e);
      }
    };
    const canvas = this.editor.canvasElement;
    canvas.addEventListener('pointerdown', handleDown);
    window.addEventListener('pointermove', handleMove);
    window.addEventListener('pointerup', handleUp);

    return function unbindEvent() {
      canvas.removeEventListener('pointerdown', handleDown);
      window.removeEventListener('pointermove', handleMove);
      window.removeEventListener('pointerup', handleUp);
    };
  }
  unbindEvent() {
    this._unbindEvent();
    this._unbindEvent = noop;
  }
  setTool(toolName: string) {
    const prevTool = this.currentTool;
    const currentTool = this.currentTool = this.toolMap.get(toolName) || null;
    if (!currentTool) {
      throw new Error(`没有 ${toolName} 对应的工具对象`);
    }
    prevTool && prevTool.inactive();
    currentTool.active();
    this.eventEmitter.emit('change', currentTool.type);
  }
  on(eventName: 'change', handler: (toolName: string) => void) {
    this.eventEmitter.on(eventName, handler);
  }
  off(eventName: 'change', handler: (toolName: string) => void) {
    this.eventEmitter.off(eventName, handler);
  }
  destroy() {
    this.currentTool?.inactive();
  }
}

结尾

工具管理类基础的设计就是这样。因为是基于生命周期去设计的,所以看起来挺像 React、Vue 的组件写法的。

我是前端西瓜哥,欢迎关注我,学习更多前端知识。



相关阅读,

EventEmitter 的核心功能实现

图形编辑器:图形和辅助线绘制的坐标问题

图形编辑器:标尺功能的实现

图形编辑器:旋转选中的元素

图形编辑器:场景坐标、视口坐标以及它们之间的转换

来,教你开发一款图形编辑器


关注公众号,后台回复 「字节」,即可获得字节前端面试资料

相关阅读

  • WebSocket 入门:简易聊天室

  • 大家好,我是前端西瓜哥,今天我们用 WebSocket 来实现一个简单的聊天室。WebSocket 是一个应用层协议,有点类似 HTTP。但和 HTTP 不一样的是,它支持真正的全双工,即不仅客户端可以
  • 在 VSCode 中像写 TypeScript 一样写 JavaScript

  • 大家好,我是前端西瓜哥。我们在 VSCode 编辑器中编写 js 代码,是会提供类型提示的。VSCode 会推断一个变量是什么类型,并在你输入内容的时候,提供对应的 API 属性或方法补全。如
  • 一起学 pixijs(2):修改图形属性

  • 大家好,我是前端西瓜哥。我们做动画、游戏、编辑器,需要根据用户的交互等操作,去实时地改变图形的属性,比如位置,颜色等信息。今天西瓜哥带大家来看看在 pixijs 怎么修改图形的属
  • Amazing!如何根据背景色自动切换黑白文字?

  • 在项目中,经常会碰到背景色不确定的场景,为了让内容文字足够清晰可见,文字和背景之间需要有足够的对比度。换句话说,当背景是深色时,文字为白色,当背景是浅色时,文字为黑色,就像这样
  • 巧用视觉障眼法,还原 3D 文字特效

  • 最近群里有这样一个有意思的问题,大家在讨论,使用 CSS 3D 能否实现如下所示的效果:这里的核心难点在于,如何利用 CSS 实现一个立体的数字?CSS 能做到吗?不是特别好实现,但是,如果仅
  • 那些炫酷的 CSS 文字效果之诗词《兔》

  • 不知不觉已经迈入2023年,今年是兔年,想到兔子就会联想到玉兔,中秋,胡萝卜,兔子不吃窝边草,这就越扯越远了,今天的主题是用纯CSS来实现各种不错的文字效果,文字则摘录古诗词中有关《
  • 今晚 B 站直播预告 - 《你所不知道的CSS》

  • 扫码直达 B 站直播间,7 号晚 20:00 点准时开始,预计时长 1 小时,感兴趣来听听:Hi 各位小伙伴,本周六(2023/01/07)晚 20:00 点,我将在 Bilibili 给大家带来一场《你所不知道的CSS》
  • 不负时光,Coco 的 2022 年终总结

  • 从来没有写过年终总结,回首 2022,感觉有很多令人难忘的瞬间,觉得今年很有必要记录一下。在回家的列车上,回忆一整年的的经历,有挣扎、有坚持、有迷茫、有喜悦,最终也有收获。用文
  • CSS 奇思妙想之酷炫倒影

  • 在 CSS 中,倒影是一种比较常见的效果。今天,我们就将尝试,使用 CSS 完成各类不同的倒影效果,话不多说,直接进入主题。实现倒影的两种方式首先,快速过一下在 CSS 中,实现倒影的 2 种

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

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

  • 大家好,我是前端西瓜哥。今天我们看看对于一款图形编辑器,应该怎么去实现工具,比如绘制矩形、选中工具,以及如何去管理它们的。项目地址,欢迎 star:https://github.com/F-star/sui
  • WebSocket 入门:简易聊天室

  • 大家好,我是前端西瓜哥,今天我们用 WebSocket 来实现一个简单的聊天室。WebSocket 是一个应用层协议,有点类似 HTTP。但和 HTTP 不一样的是,它支持真正的全双工,即不仅客户端可以
  • 在 VSCode 中像写 TypeScript 一样写 JavaScript

  • 大家好,我是前端西瓜哥。我们在 VSCode 编辑器中编写 js 代码,是会提供类型提示的。VSCode 会推断一个变量是什么类型,并在你输入内容的时候,提供对应的 API 属性或方法补全。如
  • 一起学 pixijs(2):修改图形属性

  • 大家好,我是前端西瓜哥。我们做动画、游戏、编辑器,需要根据用户的交互等操作,去实时地改变图形的属性,比如位置,颜色等信息。今天西瓜哥带大家来看看在 pixijs 怎么修改图形的属
  • Fabrie 文档 V2.7.5版本更新 | Fabrie小程序上线啦!

  • 基于用户洞察和反馈,Fabrie会定期更新迭代,用更好的自己来与你相遇,欢迎随时给我们提出问题和建议。在本次更新中,我们终于上线了「Fabrie微信小程序」,并对「团队管理页面」进行