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 的onClick、onChange);Element:特指「画布上的图形元素」(而非画布空白区域、工具栏等其他组件);PointerDown:基于 W3C 指针事件标准的「指针按下动作」(涵盖鼠标按下、手指触摸屏幕、手写笔点下等所有指针输入行为)。
简单说:onElementPointerDown = 图形元素 + 指针按下 + 事件处理。
二、核心作用
专门处理「点击图形元素」时的核心业务逻辑,比如:
- 设置图形选中状态(如代码中
setSelection({ selectedIds: [id] }),让点击的图形成为选中项); - 切换工具模式(如代码中
setActiveTool("select", "dragging"),进入拖拽模式准备移动图形); - 阻止事件冲突(如代码中
e.stopPropagation(),避免点击图形时触发画布空白区域的「取消选中」逻辑); - 扩展场景:还可实现「图形高亮」「显示编辑控制点」「触发右键菜单」等。
三、触发条件(什么时候会执行?)
必须同时满足 3 个条件,函数才会被调用:
- 触发源是「图形元素」:用户点击的是画布上已渲染的具体图形(如矩形 DOM、SVG 路径等),而非画布空白区域、工具栏等;
- 触发动作是「指针按下」 :
- 鼠标:按下鼠标左键 / 右键(默认左键,右键需额外处理
e.button); - 触摸:手指第一次接触屏幕;
- 手写笔:笔尖接触屏幕;
- 鼠标:按下鼠标左键 / 右键(默认左键,右键需额外处理
- 事件已被编辑器注册:该处理器已绑定到「图形元素的事件委托体系」中(通常由编辑器框架自动完成,无需开发者手动绑定到每个图形 DOM)。
四、技术依赖与底层原理
它不是孤立的函数,依赖 3 个核心技术支撑(和你之前学习的 React 事件系统强相关):
-
依赖 React 指针事件(Pointer Events):
- 底层基于 React 原生支持的
onPointerDown合成事件(而非onMouseDown/onTouchStart),因此天然兼容多设备(鼠标 / 触摸 / 手写笔); - 函数参数中的
e是 React 合成事件对象(SyntheticEvent),支持stopPropagation()、pointerType(区分设备类型)等特性。
- 底层基于 React 原生支持的
-
依赖事件委托机制:
- 编辑器不会给每个图形元素单独绑定
onElementPointerDown,而是将事件委托到「画布根节点」或「图形容器节点」; - 当用户点击图形时,事件冒泡到委托节点,编辑器通过
event.target识别点击的是哪个图形,再传递该图形的id给处理器(对应代码中的id参数); - 优势:即使画布有上千个图形,也不会产生大量 DOM 事件绑定,性能更优。
- 编辑器不会给每个图形元素单独绑定
-
依赖编辑器的上下文(ctx)体系:
- 函数第一个参数
ctx(上下文)是编辑器传递的「能力集合」,包含editorService(状态管理)、setActiveTool(工具模式控制)等核心 API; - 没有
ctx,onElementPointerDown无法修改选中状态、切换工具模式,本质是「编辑器给工具的通信桥梁」。
- 函数第一个参数
五、与 React 原生事件的区别(关键!避免混淆)
很多人会把它和 React 原生 onPointerDown 搞混,核心区别如下:
| 特性 | onElementPointerDown |
React 原生 onPointerDown |
|---|---|---|
| 本质 | 业务层自定义事件处理器(命名约定) | React 内置合成事件(官方 API) |
| 触发范围 | 仅图形元素触发 | 任意绑定的 DOM 元素都可触发(如 div、button) |
| 参数 | (ctx, id, e)(带编辑器上下文和图形 ID) |
(e)(仅合成事件对象) |
| 依赖 | 编辑器框架(提供 ctx、事件委托分发) | 仅 React 核心库 |
| 作用 | 处理图形编辑的业务逻辑(选中、拖拽等) | 处理通用的指针按下交互(如按钮点击、页面滚动) |
简单说:onElementPointerDown 是「编辑器基于 React 原生 onPointerDown 封装的、专门用于图形元素的业务事件」------ 原生 onPointerDown 是「基础能力」,onElementPointerDown 是「基于基础能力的业务定制」。
六、常见使用场景扩展(不止选中 + 拖拽)
除了代码中的「选中图形 + 切换拖拽模式」,onElementPointerDown 还能实现这些核心交互:
- 图形多选:结合
e.ctrlKey(按住 Ctrl 点击添加选中); - 右键菜单:判断
e.button === 2(鼠标右键),弹出图形编辑菜单(复制、删除、层级调整); - 图形缩放 / 旋转:按下图形的控制点(如四角小方块)时,切换到缩放 / 旋转模式;
- 压感支持:利用
e.pressure(触摸 / 手写笔压力值),实现图形笔触粗细变化(如手绘工具); - 拖拽复制:按住 Alt 键点击拖拽,复制当前图形。
总结
onElementPointerDown 一句话概括:图形编辑器中,专门响应「图形元素被指针按下」的业务事件处理器,基于 React 指针事件和事件委托实现,核心用于处理图形的选中、拖拽、编辑等交互逻辑,兼容多输入设备。
它是「React 事件系统」在可视化编辑器中的典型应用 ------ 将原生事件封装为业务语义明确的自定义处理器,既利用了 React 事件的跨浏览器、多设备优势,又通过编辑器的上下文和委托机制,简化了复杂图形交互的开发。
2原生事件封装
核心目标
- 统一事件绑定 / 解绑(兼容现代浏览器,聚焦主流场景);
- 事件委托(支持动态元素,减少 DOM 绑定开销);
- 多设备统一(鼠标 / 触摸 / 手写笔用一套 API);
- TS 类型提示(避免类型错误,提升开发效率)。
核心原理
- 事件流 :只关心「冒泡阶段」(默认绑定,事件从目标元素向上传播),
stopPropagation()可阻止冒泡; - 事件委托 :利用冒泡,把事件绑定到父元素,通过
e.target找到实际触发的子元素(动态新增子元素无需重新绑定); - 指针事件 :
PointerEvent是MouseEvent/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写法重复,易漏传参数,解绑时参数不一致导致失败; - 逻辑:
- 统一入口:把重复的绑定 / 解绑逻辑封装成函数,减少冗余;
- 边界处理:
!element判断避免传入null报错(紧急开发常见问题); - 类型兼容:
AddEventListenerOptions自动兼容布尔值(useCapture)和对象({ passive: true }),不用手动处理类型; - 解绑保障:强制要求
off方法传入与on一致的参数,避免内存泄漏。
(3)delegate 方法:解决动态元素问题
- 解决痛点:多个子元素绑定事件性能差,动态新增元素需重新绑定;
- 逻辑(事件委托原理):
- 事件冒泡:子元素触发事件后,事件会向上冒泡到父元素;
- 目标查找:从
e.target(实际触发元素)向上遍历,找到匹配selector的子元素; - 回调执行:用
call(target)把回调this指向匹配元素,方便业务代码获取元素属性(如this.dataset.graphId); - 优势: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)属性不一致,业务代码需区分设备; - 逻辑:
- 统一字段:
x/y直接提供坐标,pointerType明确设备类型,不用业务代码适配; - 封装方法:
stopPropagation/preventDefault直接调用,屏蔽原生 API 差异; - 保留原生:
nativeEvent作为 "逃生舱",支持特殊场景(如获取触摸点数量)。
- 统一字段:
(2)formatEvent 函数:屏蔽原生差异
- 解决痛点:原生
PointerEvent字段多,业务代码只需部分核心属性; - 逻辑:把原生
PointerEvent格式化成UnifiedPointerEvent,职责单一 ------ 只做格式转换,不处理业务逻辑,后续修改格式只需改这一个函数。
(3)bind 方法:统一绑定 + 一键解绑
- 解决痛点:多设备需绑定多个事件(
mousedown+touchstart),解绑时需逐个清理; - 逻辑:
- 复用工具:基于
EventUtils.on绑定事件,继承类型提示和边界处理; passive优化:pointermove设为passive: true提升触摸滑动流畅度,pointerdown设为false支持阻止默认行为;- 一键解绑:返回解绑函数,组件卸载时调用即可清理所有事件,避免内存泄漏。
- 复用工具:基于
三、实战使用(图形编辑器场景)
场景 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');
四、紧急开发避坑指南(关键细节)
-
回调函数必须具名 :匿名函数无法解绑(每次都是新引用),导致内存泄漏;
TypeScript// 正确:具名函数 const handleDown = (e: UnifiedPointerEvent) => { /* 逻辑 */ }; // 错误:匿名函数(无法解绑) PointerEventUtils.bind(element, { onDown: (e) => { /* 逻辑 */ } }); -
passive: true不能阻止默认行为 :需要preventDefault()(如阻止页面滚动)时,设为passive: false; -
事件委托父元素必须稳定 :委托的父元素不能动态创建 / 删除(建议用
canvasContainer/document); -
组件卸载必解绑 :React/Vue 组件卸载时,必须调用
unbindPointerEvents()或EventUtils.off,避免内存泄漏; -
防抖优化高频事件 :拖拽、滚动等高频事件用
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)避免错误
- 项目中
ToolContext和IEditorService都有明确的 TS 接口,开发时 IDE 会自动提示方法和参数(如ctx.editor.addShape会提示需要传入type、x、y等字段); - 新增工具时,严格按照接口定义使用
ctx,避免调用不存在的方法(如误写ctx.editor.addRectinstead ofctx.addShape)。
4. 避免在 ctx 中添加业务逻辑
ctx是「功能入口」,不是「业务容器」,不要给ctx新增自定义属性(如ctx.isDragging = true);- 如果工具需要临时状态(如拖拽起始位置),可以在工具内部定义局部变量(如
let dragStart: {x: number, y: number} | null = null),而非污染ctx。
五、ctx 的设计意义:为什么画布产品都需要这样的上下文?
- 解耦工具与编辑器 :工具不用依赖全局编辑器变量或 DOM 容器,只需遵守
ToolContext接口,可独立开发、测试、替换; - 统一 DOM 操作标准 :所有工具对 DOM 画布的操作都通过
ctx.editor,避免出现 "有的工具用style.left移动,有的用transform" 的混乱情况; - 便于扩展 :后续新增功能(如 DOM 元素旋转、批量编辑),只需在
IEditorService中添加方法,所有工具都能直接使用,无需修改工具代码; - 类型安全:TS 接口确保工具调用的方法和参数正确,减少生产环境 bug(尤其适合多人协作开发画布产品)。
总结:画布产品开发中 ctx 的使用口诀
- 工具交互逻辑自己管,DOM 操作全靠
ctx.editor; - 元素增删改查、视口缩放、撤销重做,直接调用接口不用猜;
- 工具切换用
setTool,体验流畅不手动; - 不污染、不硬编码,依赖接口解耦强。
按照这个逻辑使用 ctx,你开发的画布产品(DOM 渲染)会具备「模块化、可扩展、易维护」的特点,后续新增工具(如圆形、箭头、表格)时,能快速复用现有逻辑,大幅提升开发效率。