Canvas架构手记 05 鼠标事件监听 | 原生事件封装 | ctx 结构化对象

1 鼠标事件监听

分析图形选中逻辑实现

学习要点

  • React合成事件系统

  • 指针事件(Pointer Events)的优势

  • 事件委托机制

现有代码分析 (在 select.ts 中):

TypeScript 复制代码
// 工具处理器接收事件
onElementPointerDown: (ctx, id, e) => {
  e.stopPropagation();
  // 设置选中状态
  ctx.editorService.setSelection({ selectedIds: [id] });
  // 切换到拖拽模式
  ctx.setActiveTool("select", "dragging");
}

onCanvasPointerDown: (ctx, e) => {
  // 点击画布空白处清空选中
  ctx.editorService.setSelection({ selectedIds: [] });
}

实现理解

  • e.stopPropagation() 阻止事件冒泡

  • setSelection 更新全局选中状态

  • 工具模式切换实现不同交互行为

Pointer events 指针事件 - Web API | MDN

onElementPointerDown

Element.setPointerCapture() - Web API | MDN

onElementPointerDown图形编辑器 / 可视化工具中自定义的「元素指针按下事件处理器」 ------ 专门用于监听「用户点击 / 触摸画布上的具体图形元素(如矩形、圆形、文本框等)」时触发的逻辑,是基于 React 指针事件(Pointer Events)封装的业务层事件方法 ,而非 React 原生内置事件名(React 原生指针事件是 onPointerDown)。

一、本质定义

它是一个 自定义事件回调函数 ,由图形编辑器的工具系统(如 select 选择工具)注册,用于响应「图形元素被指针(鼠标 / 触摸 / 手写笔)按下」的交互行为。

命名拆解(见名知意):

  • on:事件触发的前缀(遵循前端事件命名惯例,如 React 的 onClickonChange);
  • Element:特指「画布上的图形元素」(而非画布空白区域、工具栏等其他组件);
  • PointerDown:基于 W3C 指针事件标准的「指针按下动作」(涵盖鼠标按下、手指触摸屏幕、手写笔点下等所有指针输入行为)。

简单说:onElementPointerDown = 图形元素 + 指针按下 + 事件处理

二、核心作用

专门处理「点击图形元素」时的核心业务逻辑,比如:

  1. 设置图形选中状态(如代码中 setSelection({ selectedIds: [id] }),让点击的图形成为选中项);
  2. 切换工具模式(如代码中 setActiveTool("select", "dragging"),进入拖拽模式准备移动图形);
  3. 阻止事件冲突(如代码中 e.stopPropagation(),避免点击图形时触发画布空白区域的「取消选中」逻辑);
  4. 扩展场景:还可实现「图形高亮」「显示编辑控制点」「触发右键菜单」等。

三、触发条件(什么时候会执行?)

必须同时满足 3 个条件,函数才会被调用:

  1. 触发源是「图形元素」:用户点击的是画布上已渲染的具体图形(如矩形 DOM、SVG 路径等),而非画布空白区域、工具栏等;
  2. 触发动作是「指针按下」
    • 鼠标:按下鼠标左键 / 右键(默认左键,右键需额外处理 e.button);
    • 触摸:手指第一次接触屏幕;
    • 手写笔:笔尖接触屏幕;
  3. 事件已被编辑器注册:该处理器已绑定到「图形元素的事件委托体系」中(通常由编辑器框架自动完成,无需开发者手动绑定到每个图形 DOM)。

四、技术依赖与底层原理

它不是孤立的函数,依赖 3 个核心技术支撑(和你之前学习的 React 事件系统强相关):

  1. 依赖 React 指针事件(Pointer Events)

    • 底层基于 React 原生支持的 onPointerDown 合成事件(而非 onMouseDown/onTouchStart),因此天然兼容多设备(鼠标 / 触摸 / 手写笔);
    • 函数参数中的 e 是 React 合成事件对象(SyntheticEvent),支持 stopPropagation()pointerType(区分设备类型)等特性。
  2. 依赖事件委托机制

    • 编辑器不会给每个图形元素单独绑定 onElementPointerDown,而是将事件委托到「画布根节点」或「图形容器节点」;
    • 当用户点击图形时,事件冒泡到委托节点,编辑器通过 event.target 识别点击的是哪个图形,再传递该图形的 id 给处理器(对应代码中的 id 参数);
    • 优势:即使画布有上千个图形,也不会产生大量 DOM 事件绑定,性能更优。
  3. 依赖编辑器的上下文(ctx)体系

    • 函数第一个参数 ctx(上下文)是编辑器传递的「能力集合」,包含 editorService(状态管理)、setActiveTool(工具模式控制)等核心 API;
    • 没有 ctxonElementPointerDown 无法修改选中状态、切换工具模式,本质是「编辑器给工具的通信桥梁」。

五、与 React 原生事件的区别(关键!避免混淆)

很多人会把它和 React 原生 onPointerDown 搞混,核心区别如下:

特性 onElementPointerDown React 原生 onPointerDown
本质 业务层自定义事件处理器(命名约定) React 内置合成事件(官方 API)
触发范围 仅图形元素触发 任意绑定的 DOM 元素都可触发(如 div、button)
参数 (ctx, id, e)(带编辑器上下文和图形 ID) (e)(仅合成事件对象)
依赖 编辑器框架(提供 ctx、事件委托分发) 仅 React 核心库
作用 处理图形编辑的业务逻辑(选中、拖拽等) 处理通用的指针按下交互(如按钮点击、页面滚动)

简单说:onElementPointerDown 是「编辑器基于 React 原生 onPointerDown 封装的、专门用于图形元素的业务事件」------ 原生 onPointerDown 是「基础能力」,onElementPointerDown 是「基于基础能力的业务定制」。

六、常见使用场景扩展(不止选中 + 拖拽)

除了代码中的「选中图形 + 切换拖拽模式」,onElementPointerDown 还能实现这些核心交互:

  1. 图形多选:结合 e.ctrlKey(按住 Ctrl 点击添加选中);
  2. 右键菜单:判断 e.button === 2(鼠标右键),弹出图形编辑菜单(复制、删除、层级调整);
  3. 图形缩放 / 旋转:按下图形的控制点(如四角小方块)时,切换到缩放 / 旋转模式;
  4. 压感支持:利用 e.pressure(触摸 / 手写笔压力值),实现图形笔触粗细变化(如手绘工具);
  5. 拖拽复制:按住 Alt 键点击拖拽,复制当前图形。

总结

onElementPointerDown 一句话概括:图形编辑器中,专门响应「图形元素被指针按下」的业务事件处理器,基于 React 指针事件和事件委托实现,核心用于处理图形的选中、拖拽、编辑等交互逻辑,兼容多输入设备

它是「React 事件系统」在可视化编辑器中的典型应用 ------ 将原生事件封装为业务语义明确的自定义处理器,既利用了 React 事件的跨浏览器、多设备优势,又通过编辑器的上下文和委托机制,简化了复杂图形交互的开发。

2原生事件封装

核心目标

  1. 统一事件绑定 / 解绑(兼容现代浏览器,聚焦主流场景);
  2. 事件委托(支持动态元素,减少 DOM 绑定开销);
  3. 多设备统一(鼠标 / 触摸 / 手写笔用一套 API);
  4. TS 类型提示(避免类型错误,提升开发效率)。

核心原理

  1. 事件流 :只关心「冒泡阶段」(默认绑定,事件从目标元素向上传播),stopPropagation() 可阻止冒泡;
  2. 事件委托 :利用冒泡,把事件绑定到父元素,通过 e.target 找到实际触发的子元素(动态新增子元素无需重新绑定);
  3. 指针事件PointerEventMouseEvent/TouchEvent 的超集,一套 API 兼容所有输入设备,现代浏览器(Chrome/Firefox/Edge/Safari13.1+)均支持。

一、封装核心工具

工具 1:基础事件工具(EventUtils.ts)

统一事件绑定 / 解绑 / 委托,解决重复代码和动态元素问题。

TypeScript 复制代码
/**
 * 基础事件工具:统一绑定/解绑/委托,支持TS类型提示
 */
export type EventType = keyof HTMLElementEventMap; // 所有原生事件类型(如 'click'/'pointerdown')
export type EventHandler<T extends EventType> = (e: HTMLElementEventMap[T]) => void;

export const EventUtils = {
  /**
   * 绑定事件
   * @param element 目标DOM元素
   * @param type 事件类型(如 'pointerdown'/'click')
   * @param handler 事件回调
   * @param options 事件配置(如 passive: true 优化触摸滑动)
   */
  on: <T extends EventType>(
    element: HTMLElement | Document | Window,
    type: T,
    handler: EventHandler<T>,
    options?: AddEventListenerOptions
  ) => {
    if (!element) return;
    element.addEventListener(type, handler as EventListener, options);
  },

  /**
   * 解绑事件(必须与绑定的参数完全一致,否则解绑失败)
   */
  off: <T extends EventType>(
    element: HTMLElement | Document | Window,
    type: T,
    handler: EventHandler<T>,
    options?: AddEventListenerOptions
  ) => {
    if (!element) return;
    element.removeEventListener(type, handler as EventListener, options);
  },

  /**
   * 事件委托:给父元素绑定事件,监听子元素触发
   * @param parent 父元素(如 ul)
   * @param type 事件类型
   * @param selector 子元素选择器(如 'li'/'[data-id]')
   * @param handler 事件回调(this 指向匹配的子元素)
   */
  delegate: <T extends EventType>(
    parent: HTMLElement | Document | Window,
    type: T,
    selector: string,
    handler: (this: HTMLElement, e: HTMLElementEventMap[T]) => void
  ) => {
    EventUtils.on(parent, type, (e) => {
      let target = e.target as HTMLElement;
      // 从触发元素向上查找匹配 selector 的父元素
      while (target && !target.matches(selector)) {
        target = target.parentElement as HTMLElement;
        if (target === parent) { // 找到父元素仍不匹配,终止
          target = null;
          break;
        }
      }
      // 找到匹配元素,执行回调(绑定 this 为匹配元素)
      target && handler.call(target, e);
    });
  },

  /**
   * 防抖工具(高频事件优化,如拖拽、滚动)
   */
  debounce: <T extends (...args: any[]) => void>(fn: T, delay = 100) => {
    let timer: NodeJS.Timeout | null = null;
    return (...args: Parameters<T>) => {
      timer && clearTimeout(timer);
      timer = setTimeout(() => fn(...args), delay);
    };
  },
};

工具 2:统一指针事件(PointerEventUtils.ts)

屏蔽多设备差异,一套 API 兼容鼠标 / 触摸 / 手写笔。

TypeScript 复制代码
import { EventUtils, EventHandler } from './EventUtils';

/**
 * 统一指针事件:一套API兼容鼠标/触摸/手写笔
 */
export type PointerEventType = 'down' | 'move' | 'up' | 'cancel';
export interface UnifiedPointerEvent {
  type: `pointer${Capitalize<PointerEventType>}`; // 事件类型(如 'pointerDown')
  pointerType: 'mouse' | 'touch' | 'pen'; // 设备类型
  x: number; // 相对视口X坐标
  y: number; // 相对视口Y坐标
  target: HTMLElement; // 触发元素
  nativeEvent: Event; // 原生事件对象(供特殊处理)
  stopPropagation: () => void; // 阻止冒泡
  preventDefault: () => void; // 阻止默认行为
}

export interface PointerEventOptions {
  onDown?: (e: UnifiedPointerEvent) => void;
  onMove?: (e: UnifiedPointerEvent) => void;
  onUp?: (e: UnifiedPointerEvent) => void;
  onCancel?: (e: UnifiedPointerEvent) => void;
}

export const PointerEventUtils = {
  /**
   * 给元素绑定统一指针事件
   * @param element 目标元素(如画布、图形)
   * @param options 事件回调(按需传入)
   * @returns 解绑函数(组件卸载时调用)
   */
  bind: (element: HTMLElement, options: PointerEventOptions) => {
    const { onDown, onMove, onUp, onCancel } = options;

    // 格式化事件对象(统一输出格式,屏蔽原生差异)
    const formatEvent = (nativeEvent: PointerEvent): UnifiedPointerEvent => ({
      type: nativeEvent.type as `pointer${Capitalize<PointerEventType>}`,
      pointerType: nativeEvent.pointerType as 'mouse' | 'touch' | 'pen',
      x: nativeEvent.clientX,
      y: nativeEvent.clientY,
      target: nativeEvent.target as HTMLElement,
      nativeEvent,
      stopPropagation: () => nativeEvent.stopPropagation(),
      preventDefault: () => nativeEvent.preventDefault(),
    });

    // 绑定原生 PointerEvent(现代浏览器直接支持)
    const handleDown = (e: PointerEvent) => onDown && onDown(formatEvent(e));
    const handleMove = (e: PointerEvent) => onMove && onMove(formatEvent(e));
    const handleUp = (e: PointerEvent) => onUp && onUp(formatEvent(e));
    const handleCancel = (e: PointerEvent) => onCancel && onCancel(formatEvent(e));

    EventUtils.on(element, 'pointerdown', handleDown, { passive: false });
    EventUtils.on(element, 'pointermove', handleMove, { passive: true });
    EventUtils.on(element, 'pointerup', handleUp, { passive: true });
    EventUtils.on(element, 'pointercancel', handleCancel, { passive: true });

    // 返回解绑函数(方便组件卸载时清理,避免内存泄漏)
    return () => {
      EventUtils.off(element, 'pointerdown', handleDown);
      EventUtils.off(element, 'pointermove', handleMove);
      EventUtils.off(element, 'pointerup', handleUp);
      EventUtils.off(element, 'pointercancel', handleCancel);
    };
  },
};

二、封装逻辑拆解

所有封装都是为了解决原生事件的痛点,核心思路是「抽象共性、屏蔽差异、统一 API」。

1. 基础事件工具(EventUtils.ts):逻辑拆解

(1)类型定义:TS 类型安全的核心
TypeScript 复制代码
export type EventType = keyof HTMLElementEventMap;
export type EventHandler<T extends EventType> = (e: HTMLElementEventMap[T]) => void;
  • 解决痛点:原生事件类型多、易写错,事件对象属性不明确;
  • 逻辑:keyof HTMLElementEventMap 自动推导所有合法事件名(如 'pointerdown'),EventHandler 关联事件类型和事件对象 ------ 传入 'pointerdown' 时,TS 自动提示 e.pointerType/clientX 等属性,不用查文档。
(2)on/off 方法:抽象重复绑定逻辑
  • 解决痛点:原生 addEventListener/removeEventListener 写法重复,易漏传参数,解绑时参数不一致导致失败;
  • 逻辑:
    1. 统一入口:把重复的绑定 / 解绑逻辑封装成函数,减少冗余;
    2. 边界处理:!element 判断避免传入 null 报错(紧急开发常见问题);
    3. 类型兼容:AddEventListenerOptions 自动兼容布尔值(useCapture)和对象({ passive: true }),不用手动处理类型;
    4. 解绑保障:强制要求 off 方法传入与 on 一致的参数,避免内存泄漏。
(3)delegate 方法:解决动态元素问题
  • 解决痛点:多个子元素绑定事件性能差,动态新增元素需重新绑定;
  • 逻辑(事件委托原理):
    1. 事件冒泡:子元素触发事件后,事件会向上冒泡到父元素;
    2. 目标查找:从 e.target(实际触发元素)向上遍历,找到匹配 selector 的子元素;
    3. 回调执行:用 call(target) 把回调 this 指向匹配元素,方便业务代码获取元素属性(如 this.dataset.graphId);
    4. 优势:1 个父元素绑定替代 N 个子元素绑定,动态新增元素自动支持事件。

2. 统一指针事件(PointerEventUtils.ts):逻辑拆解

(1)UnifiedPointerEvent:统一输出格式
TypeScript 复制代码
export interface UnifiedPointerEvent {
  type: `pointer${Capitalize<PointerEventType>}`;
  pointerType: 'mouse' | 'touch' | 'pen';
  x: number;
  y: number;
  // ... 其他属性
}
  • 解决痛点:鼠标事件(clientX)和触摸事件(touches[0].clientX)属性不一致,业务代码需区分设备;
  • 逻辑:
    1. 统一字段:x/y 直接提供坐标,pointerType 明确设备类型,不用业务代码适配;
    2. 封装方法:stopPropagation/preventDefault 直接调用,屏蔽原生 API 差异;
    3. 保留原生:nativeEvent 作为 "逃生舱",支持特殊场景(如获取触摸点数量)。
(2)formatEvent 函数:屏蔽原生差异
  • 解决痛点:原生 PointerEvent 字段多,业务代码只需部分核心属性;
  • 逻辑:把原生 PointerEvent 格式化成 UnifiedPointerEvent,职责单一 ------ 只做格式转换,不处理业务逻辑,后续修改格式只需改这一个函数。
(3)bind 方法:统一绑定 + 一键解绑
  • 解决痛点:多设备需绑定多个事件(mousedown+touchstart),解绑时需逐个清理;
  • 逻辑:
    1. 复用工具:基于 EventUtils.on 绑定事件,继承类型提示和边界处理;
    2. passive 优化:pointermove 设为 passive: true 提升触摸滑动流畅度,pointerdown 设为 false 支持阻止默认行为;
    3. 一键解绑:返回解绑函数,组件卸载时调用即可清理所有事件,避免内存泄漏。

三、实战使用(图形编辑器场景)

场景 1:单个图形元素绑定选中 / 拖拽事件

TypeScript 复制代码
import { PointerEventUtils } from './PointerEventUtils';

// 图形元素(假设已渲染到页面)
const graphElement = document.querySelector('[data-graph-id="rect_123"]') as HTMLElement;
const graphId = 'rect_123';

// 绑定指针事件(兼容鼠标/触摸)
const unbindPointerEvents = PointerEventUtils.bind(graphElement, {
  // 指针按下(选中+切换拖拽模式)
  onDown: (e) => {
    e.stopPropagation(); // 阻止冒泡到画布,避免清空选中
    console.log(`选中图形 ${graphId},设备:${e.pointerType}`);
    
    // 编辑器选中逻辑(替换为你的业务代码)
    // ctx.editorService.setSelection({ selectedIds: [graphId] });
    // 切换拖拽模式
    // ctx.setActiveTool("select", "dragging");
  },

  // 指针移动(拖拽逻辑)
  onMove: (e) => {
    console.log(`拖拽坐标:${e.x}, ${e.y}`);
    // 更新图形位置(替换为你的业务代码)
    // graphElement.style.left = `${e.x}px`;
    // graphElement.style.top = `${e.y}px`;
  },

  // 指针抬起(结束拖拽)
  onUp: (e) => {
    console.log(`结束拖拽图形 ${graphId}`);
    // 切换回默认模式
    // ctx.setActiveTool("select", "default");
  },
});

// 组件/页面卸载时解绑(必写!避免内存泄漏)
// unbindPointerEvents();

场景 2:画布空白处绑定取消选中事件(事件委托)

TypeScript 复制代码
import { EventUtils } from './EventUtils';

// 画布容器(父元素,稳定存在)
const canvasContainer = document.querySelector('.canvas-container') as HTMLElement;

// 事件委托:监听所有图形点击(可选,也可单独绑定)
EventUtils.delegate(canvasContainer, 'pointerdown', '[data-graph-id]', function() {
  console.log('点击图形:', this.dataset.graphId);
});

// 画布空白处点击:清空选中
EventUtils.on(canvasContainer, 'pointerdown', (e) => {
  const target = e.target as HTMLElement;
  // 未匹配到任何图形元素,视为空白处点击
  if (!target.matches('[data-graph-id]')) {
    console.log('点击空白处,清空选中');
    // ctx.editorService.setSelection({ selectedIds: [] });
  }
});

场景 3:动态新增图形(自动支持事件)

TypeScript 复制代码
// 动态创建图形元素
const createNewGraph = (id: string) => {
  const div = document.createElement('div');
  div.dataset.graphId = id;
  div.className = 'graph';
  canvasContainer.appendChild(div);
  return div;
};

// 新增图形:无需重新绑定事件(事件委托自动生效)
const newGraph = createNewGraph('circle_456');

四、紧急开发避坑指南(关键细节)

  1. 回调函数必须具名 :匿名函数无法解绑(每次都是新引用),导致内存泄漏;

    TypeScript 复制代码
    // 正确:具名函数
    const handleDown = (e: UnifiedPointerEvent) => { /* 逻辑 */ };
    // 错误:匿名函数(无法解绑)
    PointerEventUtils.bind(element, { onDown: (e) => { /* 逻辑 */ } });
  2. passive: true 不能阻止默认行为 :需要 preventDefault()(如阻止页面滚动)时,设为 passive: false

  3. 事件委托父元素必须稳定 :委托的父元素不能动态创建 / 删除(建议用 canvasContainer/document);

  4. 组件卸载必解绑 :React/Vue 组件卸载时,必须调用 unbindPointerEvents()EventUtils.off,避免内存泄漏;

  5. 防抖优化高频事件 :拖拽、滚动等高频事件用 EventUtils.debounce 优化性能。

总结

这套封装的核心逻辑:用 TS 类型约束输入输出,用函数抽象重复逻辑,用事件委托解决动态元素问题,用格式转换屏蔽多设备差异

3 ctx 结构化对象

在你这个 DOM 渲染的 Canvas 平台 (注:此处 "Canvas" 是产品名称,实际用 DOM 元素渲染图形 / 元素)中,ctx 是工具与编辑器核心能力的「统一交互入口」 ------ 它是 ToolContext 接口的实例,本质是工具的 "依赖包 + 功能调用桥梁"

对于你后续开发同类画布产品(如图形编辑器、在线白板、低代码平台画布),掌握 ctx 的用法能帮你实现「模块化工具设计」,让工具只关注交互逻辑(点击、拖拽、绘制),而编辑器核心能力(元素增删改、视口控制、撤销重做)统一通过 ctx 调用,避免代码耦合。下面从「是什么、有什么、怎么用、开发避坑」四个维度,结合 DOM 渲染场景讲透:

一、先明确:这个 ctx 到底是什么?(DOM 渲染场景适配)

和浏览器原生 Canvas 的绘图上下文(getContext('2d'))完全不同!你项目中的 ctx 是:

  • 工具上下文对象:专门为「交互工具」(如选择工具、矩形工具、文本工具)设计,传递工具所需的所有核心依赖;
  • DOM 渲染友好:所有功能都适配 DOM 元素操作(如新增 DOM 图形、修改 DOM 样式 / 位置、控制 DOM 视口),无需关心 Canvas 绘图 API;
  • 解耦层 :工具不用直接依赖编辑器全局变量或 DOM 元素,只需通过 ctx 调用接口,实现「工具逻辑」和「编辑器底层实现」的分离。

简单说:ctx 是给工具用的「万能遥控器」,拿着它就能操作整个 DOM 画布,不用自己去碰复杂的底层逻辑。

二、ctx 的核心结构(固定不变,按接口调用即可)

根据项目代码,ctx 的结构由 ToolContext 接口定义,只有 2 个核心属性,但能覆盖所有画布操作场景:

TypeScript 复制代码
export interface ToolContext {
  editor: IEditorService; // 编辑器核心服务(DOM 画布操作的核心)
  setTool: (tool: ToolType) => void; // 工具切换函数(如矩形工具→选择工具)
}

1. 核心属性 1:ctx.editor(IEditorService 实例)------ DOM 画布的 "操作引擎"

ctx.editor 是编辑器暴露给工具的「功能集合」,所有和 DOM 画布相关的操作(新增 DOM 元素、移动 DOM 位置、缩放视口、撤销重做)都通过它调用。

以下是按「开发频率排序」的核心功能(适配 DOM 渲染场景):

功能分类 核心方法 DOM 渲染场景说明 开发用例
元素选中管理 setSelection([id]) 选中指定 DOM 元素(如给 div 图形加高亮边框) 点击 DOM 图形时,选中该元素
resetSelection() 清空选中(移除所有 DOM 元素的高亮状态) 点击画布空白处取消选中
元素创建(DOM) addShape({type: 'rect', ...}) 新增图形 DOM 元素(如创建 div 矩形、span 圆形) 矩形工具拖拽后,生成新的 DOM 矩形
addImage({url, x, y}) 新增图片 DOM 元素(img 标签) 图片工具上传后,在画布添加 img 元素
addText({content, style}) 新增文本 DOM 元素(span 或 div 标签) 文本工具点击后,创建可编辑文本 DOM
元素变换(DOM) transformElement(id, {x, y}) 移动 DOM 元素位置(修改 style.left/top 或 transform) 选择工具拖拽 DOM 图形时更新位置
resizeElement(id, {width, height}) 调整 DOM 元素尺寸(修改 style.width/height) 拖拽 DOM 元素控制点缩放大小
视口控制(DOM) zoomAt(scale) 缩放 DOM 画布(修改容器 transform: scale) 鼠标滚轮缩放整个 DOM 画布
moveViewport({dx, dy}) 平移 DOM 画布(修改容器 scrollLeft/scrollTop) 按住空格拖拽画布时平移
元素删除(DOM) deleteElement(id) 删除指定 DOM 元素(从画布容器中移除 div/img) 选中 DOM 元素后按 Delete 键删除
撤销 / 重做(DOM) undo()/redo() 恢复 DOM 画布的上一步 / 下一步状态(如撤销 DOM 移动) 用户按 Ctrl+Z 撤销操作
复制粘贴(DOM) copySelection()/paste() 复制选中的 DOM 元素(深拷贝 div 及其样式) 按 Ctrl+C 复制 DOM 图形,Ctrl+V 粘贴

2. 核心属性 2:setTool (toolType) ------ 工具间的 "切换开关"

  • 作用:在当前工具中,一键切换到另一个工具(无需关心工具初始化逻辑);
  • 适配 DOM 场景:切换工具后,编辑器会自动更新事件监听(如从 "绘制矩形" 事件切换到 "拖拽 DOM" 事件);
  • 常用场景:创建元素后自动切换到选择工具(如绘制完 DOM 矩形后,切换到 select 工具拖拽它)。

三、ctx 的实战使用:3 个核心场景

以下示例完全贴合你的项目代码风格,直接复制到工具文件(如 select.ts、rect.ts)即可用,覆盖 80% 画布开发需求:

场景 1:选择工具(select.ts)------ 操作 DOM 元素选中与拖拽

TypeScript 复制代码
// 点击 DOM 图形时选中
onElementPointerDown: (ctx, id, e) => {
  e.stopPropagation(); // 阻止事件冒泡到画布(避免清空选中)
  ctx.editor.setSelection([id]); // 选中当前 DOM 图形(添加高亮样式)
  // 记录拖拽起始位置(配合 transformElement 实现 DOM 移动)
  const domElement = document.querySelector(`[data-id="${id}"]`) as HTMLElement;
  const rect = domElement.getBoundingClientRect();
  ctx._dragStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
},

// 拖拽 DOM 图形时移动位置
onElementPointerMove: (ctx, id, e) => {
  if (!ctx._dragStart) return;
  // 调用 editor 移动 DOM 元素(内部会修改 DOM 的位置样式)
  ctx.editor.transformElement(id, {
    x: e.clientX - ctx._dragStart.x,
    y: e.clientY - ctx._dragStart.y
  });
},

// 点击画布空白处清空选中
onCanvasPointerDown: (ctx) => {
  ctx.editor.resetSelection(); // 清空所有 DOM 元素的选中状态
},

场景 2:矩形工具(rect.ts)------ 创建 DOM 矩形 + 自动切换工具

TypeScript 复制代码
onCanvasPointerDown: (ctx, point) => {
  // 1. 调用 editor 新增 DOM 矩形元素(内部会创建 div 标签,添加样式)
  const rectId = ctx.editor.addShape({
    type: 'rect',
    x: point.x,
    y: point.y,
    width: 0,
    height: 0,
    style: { backgroundColor: '#007bff', border: '1px solid #000' }
  });

  // 2. 记录拖拽起始点(用于后续调整 DOM 矩形尺寸)
  ctx._drawStart = { point, rectId };
},

onCanvasPointerMove: (ctx, point) => {
  if (!ctx._drawStart) return;
  const { rectId, point: startPoint } = ctx._drawStart;
  // 3. 调整 DOM 矩形尺寸(修改 div 的 width/height)
  ctx.editor.resizeElement(rectId, {
    width: point.x - startPoint.x,
    height: point.y - startPoint.y
  });
},

onCanvasPointerUp: (ctx) => {
  if (!ctx._drawStart) return;
  // 4. 绘制完成后,自动切换到选择工具(可拖拽 DOM 矩形)
  ctx.setTool('select');
  delete ctx._drawStart;
},

场景 3:图片工具(image.ts)------ 新增图片 DOM 元素

TypeScript 复制代码
// 假设已通过上传组件获取图片 URL
onImageSelect: (ctx, url, point) => {
  // 调用 editor 新增 img 标签 DOM 元素
  const imgId = ctx.editor.addImage({
    url,
    x: point.x,
    y: point.y,
    width: 200,
    height: 150,
    style: { objectFit: 'cover' }
  });
  // 选中新增的图片 DOM,切换到选择工具
  ctx.editor.setSelection([imgId]);
  ctx.setTool('select');
},

四、开发画布产品时,如何高效使用 ctx?(核心原则)

1. 工具只关心 "交互逻辑",DOM 操作全丢给 ctx.editor

  • 错误做法:在工具中直接创建 DOM 元素(document.createElement('div'))、修改样式(dom.style.left = '10px');
  • 正确做法:工具只处理「用户点击 / 拖拽」的交互流程,所有 DOM 操作(创建、移动、缩放)都通过 ctx.editor 调用 ------ 这样后续修改 DOM 渲染逻辑(如从 style 改为 transform)时,不用修改所有工具代码。

2. 工具间协作靠 ctx.setTool,避免硬编码

  • 示例:创建完 DOM 元素(矩形、图片)后,自动切换到选择工具(ctx.setTool('select')),用户无需手动切换,体验更流畅;
  • 扩展:如果需要从选择工具切换到 "旋转工具",直接调用 ctx.setTool('rotate') 即可,编辑器会自动处理事件监听切换。

3. 利用类型定义(TS)避免错误

  • 项目中 ToolContextIEditorService 都有明确的 TS 接口,开发时 IDE 会自动提示方法和参数(如 ctx.editor.addShape 会提示需要传入 typexy 等字段);
  • 新增工具时,严格按照接口定义使用 ctx,避免调用不存在的方法(如误写 ctx.editor.addRect instead of ctx.addShape)。

4. 避免在 ctx 中添加业务逻辑

  • ctx 是「功能入口」,不是「业务容器」,不要给 ctx 新增自定义属性(如 ctx.isDragging = true);
  • 如果工具需要临时状态(如拖拽起始位置),可以在工具内部定义局部变量(如 let dragStart: {x: number, y: number} | null = null),而非污染 ctx

五、ctx 的设计意义:为什么画布产品都需要这样的上下文?

  1. 解耦工具与编辑器 :工具不用依赖全局编辑器变量或 DOM 容器,只需遵守 ToolContext 接口,可独立开发、测试、替换;
  2. 统一 DOM 操作标准 :所有工具对 DOM 画布的操作都通过 ctx.editor,避免出现 "有的工具用 style.left 移动,有的用 transform" 的混乱情况;
  3. 便于扩展 :后续新增功能(如 DOM 元素旋转、批量编辑),只需在 IEditorService 中添加方法,所有工具都能直接使用,无需修改工具代码;
  4. 类型安全:TS 接口确保工具调用的方法和参数正确,减少生产环境 bug(尤其适合多人协作开发画布产品)。

总结:画布产品开发中 ctx 的使用口诀

  • 工具交互逻辑自己管,DOM 操作全靠 ctx.editor
  • 元素增删改查、视口缩放、撤销重做,直接调用接口不用猜;
  • 工具切换用 setTool,体验流畅不手动;
  • 不污染、不硬编码,依赖接口解耦强。

按照这个逻辑使用 ctx,你开发的画布产品(DOM 渲染)会具备「模块化、可扩展、易维护」的特点,后续新增工具(如圆形、箭头、表格)时,能快速复用现有逻辑,大幅提升开发效率。

相关推荐
再睡一夏就好7 分钟前
深入Linux线程:从轻量级进程到双TCB架构
linux·运维·服务器·c++·学习·架构·线程
墨香幽梦客7 分钟前
HA高可用架构选型:确保企业系统稳定运行的基石
架构
wyzqhhhh9 分钟前
京东啊啊啊啊啊
开发语言·前端·javascript
JIngJaneIL9 分钟前
基于java+ vue助农电商系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
蒙奇D索大10 分钟前
【11408学习记录】考研英语长难句拆解三步法:三步拆解2020年真题,攻克阅读难点
笔记·学习·考研·改行学it
好奇龙猫11 分钟前
【日语学习-日语知识点小记-构建基础-JLPT-N3阶段-二阶段(32):本階段が終わります】
学习
SmartBrain12 分钟前
洞察:阿里通义DeepResearch 技术
大数据·人工智能·语言模型·架构
悠闲漫步者17 分钟前
第2章 MCS-51单片机的串口和最小系统(学习笔记)
笔记·学习·51单片机
想学后端的前端工程师19 分钟前
【Java集合框架深度解析:从入门到精通-后端技术栈】
前端·javascript·vue.js
shenghaide_jiahu23 分钟前
数学分析简明教程——6.5
学习