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 渲染)会具备「模块化、可扩展、易维护」的特点,后续新增工具(如圆形、箭头、表格)时,能快速复用现有逻辑,大幅提升开发效率。

相关推荐
LabVIEW开发4 小时前
LabVIEW QMH 队列消息处理架构
架构·labview·labview知识·labview功能·labview程序
二哈赛车手4 小时前
新人笔记---ApiFox的一些常见使用出错
java·笔记·spring
代码搬运媛4 小时前
Jest 测试框架详解与实现指南
前端
吃好睡好便好4 小时前
在Matlab中绘制横直方图
开发语言·学习·算法·matlab
counterxing5 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
wangqiaowq5 小时前
windows下nginx的安装
linux·服务器·前端
rising start5 小时前
二、全面理解MySQL架构
mysql·架构
nashane5 小时前
HarmonyOS 6学习:CapsLock键失效诊断与长截图完整实现指南
学习·华为·harmonyos
之歆5 小时前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜5 小时前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite