用户交互与事件
目的与范围
本页介绍 relation-graph 中的事件系统架构,说明用户交互如何被捕获、处理,并传播到应用代码中。该系统提供一个统一的事件模型,可在所有受支持的框架(Vue、React、Svelte)中保持一致的工作方式。
关于特定事件类型与内部事件分发器架构的详细信息,请参见 Event System Architecture。关于处理点击、拖拽、手势等用户操作的具体示例,请参见 User Actions & Interactions。
事件系统概览
relation-graph 事件系统遵循一种分层事件流模式:用户交互在 DOM 层被捕获,经由核心图逻辑处理,最终向应用层事件处理器发射。该架构将以下关注点分离:
- 用户操作捕获 - 绑定在图元素(节点、线、画布)上的 DOM 事件监听器
- 核心处理 -
RelationGraphWith7Event及相关类中的业务逻辑 - 事件发射 - 通过
emitEvent()进行标准化事件广播 - 应用处理器 -
RGListeners接口中的用户自定义回调
所有事件均为类型安全,TypeScript 定义为事件名、参数与返回值提供自动补全与校验。
事件流架构
从 DOM 到应用处理器的事件传播
graph TB
subgraph DOM["DOM 事件"]
MouseEvent["MouseEvent/TouchEvent"]
end
subgraph Components["组件层"]
RGNodePeel["@mousedown
RGNodePeel"]
RGLinePath["@click
RGLinePath"]
RGCanvas["@mousedown
RGCanvas"]
end
subgraph Core["RelationGraphWith7Event"]
onNodeDragStart["onNodeDragStart(node, e)"]
onNodeClick["onNodeClick(node, e)"]
onLineClick["onLineClick(line, e)"]
onCanvasClick["onCanvasClick(e)"]
expandOrCollapseNode["expandOrCollapseNode(node, e)"]
startCreatingNodePlot["startCreatingNodePlot(e, setting)"]
startCreatingLinePlot["startCreatingLinePlot(e, setting)"]
end
subgraph Base["RelationGraphBase"]
emitEvent["emitEvent(eventName, ...args)"]
defaultEventHandler["defaultEventHandler()"]
eventHandlers["eventHandlers: RGEventHandler[]"]
_emitHook["_emitHook: RGEventEmitHook"]
end
subgraph App["应用层"]
listeners["listeners: RGListeners"]
onNodeClickCB["listeners.onNodeClick?"]
onNodeDragStartCB["listeners.onNodeDragStart?"]
customHandler["自定义 RGEventHandler"]
end
MouseEvent --> RGNodePeel
MouseEvent --> RGLinePath
MouseEvent --> RGCanvas
RGNodePeel -->|"@mousedown"| onNodeDragStart
RGNodePeel -->|"检测到 click"| onNodeClick
RGLinePath --> onLineClick
RGCanvas --> onCanvasClick
onNodeDragStart --> emitEvent
onNodeClick --> emitEvent
onLineClick --> emitEvent
onCanvasClick --> emitEvent
expandOrCollapseNode --> emitEvent
startCreatingNodePlot --> emitEvent
startCreatingLinePlot --> emitEvent
emitEvent --> defaultEventHandler
emitEvent --> eventHandlers
emitEvent --> _emitHook
defaultEventHandler --> listeners
listeners --> onNodeClickCB
listeners --> onNodeDragStartCB
eventHandlers --> customHandler
_emitHook --> App事件处理链:
- DOM 事件捕获 - 框架组件绑定原生事件监听器(
@mousedown、@click、@wheel) - 处理方法调用 - 组件调用
RelationGraphWith7Event上对应的方法(例如onNodeDragStart()) - 业务逻辑执行 - 处理方法处理交互、更新
dataProvider、检查配置项 - 事件发射 - 处理方法调用
this.emitEvent(RGEventNames.onNodeDragStart, node, e) - 多层分发 -
emitEvent()按顺序调用处理器:defaultEventHandler()→ 检查this.listeners- 自定义
eventHandlers[]→ 用户注册的中间件 _emitHook()→ 框架特定的 emit(Vue$emit)
- 应用回调 -
RGListeners中的用户自定义函数使用事件数据执行
核心事件组件
RGEventNames 枚举
系统中的所有事件都由 RGEventNames enum 中定义的常量标识。这确保了类型安全,并防止事件名拼写错误:
enum RGEventNames {
onReady = 'onReady',
onNodeClick = 'onNodeClick',
onNodeDragStart = 'onNodeDragStart',
onNodeDragging = 'onNodeDragging',
onNodeDragEnd = 'onNodeDragEnd',
onLineClick = 'onLineClick',
onCanvasClick = 'onCanvasClick',
onCanvasDragging = 'onCanvasDragging',
onCanvasDragEnd = 'onCanvasDragEnd',
// ... 总计 20+ 种事件类型
}
完整事件列表:
| 类别 | 事件 |
|---|---|
| 节点事件 | onNodeClick, onNodeExpand, onNodeCollapse, onNodeDragStart, onNodeDragging, onNodeDragEnd |
| 线事件 | onLineClick, onLineBeCreated, onLineVertexDropped, beforeCreateLine |
| 画布事件 | onCanvasClick, onCanvasDragStart, onCanvasDragging, onCanvasDragEnd, onCanvasSelectionEnd |
| 视图事件 | beforeZoomStart, onZoomEnd, onViewResize, onFullscreen |
| 编辑事件 | onResizeStart, beforeNodeResize, onResizeEnd |
| 键盘事件 | onKeyboardDown, onKeyboardUp |
| 生命周期事件 | onReady, onForceLayoutFinish, beforeScrollStart |
| 右键菜单 | onContextmenu |
| 数据事件 | beforeAddNodes, beforeAddLines |
RGListeners 接口
RGListeners 接口定义了所有事件处理回调的类型签名。应用通过实现该接口来处理事件:
interface RGListeners {
onReady?: (graphInstance: RelationGraphInstance) => void;
onNodeClick?: (node: RGNode, e: RGUserEvent) => boolean | void | Promise<boolean | void>;
onNodeDragging?: (node: RGNode, newX: number, newY: number, buffX: number, buffY: number, e: RGUserEvent) => void | RGPosition | undefined;
onCanvasDragging?: (newX: number, newY: number, buffX: number, buffY: number) => void | RGPosition | undefined;
// ... 其他所有事件处理器
}
返回值模式:
boolean | void- 返回false取消默认行为(例如beforeNodeResize、beforeCreateLine)RGPosition | undefined- 返回坐标覆盖计算后的位置(例如onNodeDragging、onCanvasDragging)void- 无返回值,仅通知(大多数事件)
RGUserEvent 类型
系统将浏览器事件归一化为统一的 RGUserEvent 类型,同时支持鼠标与触摸交互:
type RGUserEvent = MouseEvent | TouchEvent;
诸如 getClientCoordinate() 与 isSupportTouch() 之类的工具函数屏蔽了鼠标与触摸事件的差异,使核心逻辑能以统一方式处理两类交互。
事件注册与处理
方法 1:声明式监听器(推荐)
创建 RelationGraph 组件时,通过组件 props 传入事件处理器:
<RelationGraph
options={graphOptions}
onNodeClick={(node, e) => {
console.log('Node clicked:', node.text);
}}
onNodeDragEnd={(node, e, xBuff, yBuff) => {
console.log(`Node ${node.id} moved by (${xBuff}, ${yBuff})`);
}}
onCanvasClick={(e) => {
console.log('Canvas clicked at', e.clientX, e.clientY);
}}
/>
该方式在 Vue、React、Svelte 实现中均以完全相同的方式工作。
方法 2:编程式注册
对于动态事件处理,使用 addEventHandler() 注册可接收所有事件的自定义处理器:
const graphInstance = await getGraphInstance();
graphInstance.addEventHandler((eventName, ...args) => {
if (eventName === RGEventNames.onNodeClick) {
const [node, event] = args;
console.log('Intercepted node click:', node.id);
return false; // Prevent default behavior
}
});
可以注册多个处理器,并按注册顺序执行。返回 undefined 以继续到下一个处理器,或返回一个值以短路该链路。
方法 3:事件 Emit Hook(框架集成)
平台特定实现可注册一个 emit hook,用于桥接到框架原生事件系统(例如 Vue 的 $emit):
graphInstance.setEventEmitHook((eventName, ...args, callback) => {
// Forward to framework's event system
this.$emit(eventName, ...args);
// Call the callback to get return value from parent
callback(parentReturnValue);
});
Vue2/Vue3 适配器在内部使用它来集成组件的 emit() 功能。
用户交互到处理方法的映射
DOM 事件到核心处理方法的映射
| 用户交互 | DOM 事件 | 组件 | 处理方法 | 发射的事件 |
|---|---|---|---|---|
| 点击节点 | @click / @mousedown |
RGNodePeel | onNodeClick(node, e) |
RGEventNames.onNodeClick |
| 拖拽节点 | @mousedown + mousemove |
RGNodePeel | onNodeDragStart(node, e) |
RGEventNames.onNodeDragStartRGEventNames.onNodeDraggingRGEventNames.onNodeDragEnd |
| 点击连线 | @click |
RGLinePath | onLineClick(line, e) |
RGEventNames.onLineClick |
| 点击画布 | @mousedown |
RGCanvas | onCanvasClick(e) |
RGEventNames.onCanvasClick |
| 拖拽画布 | @mousedown + mousemove |
RGCanvas | onCanvasDragStart(e) |
RGEventNames.onCanvasDragStartRGEventNames.onCanvasDraggingRGEventNames.onCanvasDragEnd |
| 鼠标滚轮 | @wheel |
RGCanvas | onMouseWheel(e) |
RGEventNames.beforeZoomStartRGEventNames.onZoomEnd |
| 展开/折叠 | @click |
展开按钮 | expandOrCollapseNode(node, e) |
RGEventNames.onNodeExpandRGEventNames.onNodeCollapse |
| 调整节点大小 | @mousedown |
RGEditingNodeController | onResizeStart(type, e) |
RGEventNames.onResizeStartRGEventNames.beforeNodeResizeRGEventNames.onResizeEnd |
| 右键菜单 | @contextmenu |
多处 | onContextMenu(e, type, obj) |
RGEventNames.onContextmenu |
| 键盘 | @keydown / @keyup |
Document | onKeyDown(e) / onKeyUp(e) |
RGEventNames.onKeyboardDownRGEventNames.onKeyboardUp |
事件处理管线
emitEvent() 方法的内部结构
graph TB
Start["emitEvent(eventName: RGEventNames, ...args)"]
subgraph Phase1["阶段 1:defaultEventHandler()"]
CheckEvent["根据 eventName 进行 switch"]
FindListener["查找 this.listeners[eventName]"]
ExecDefault["执行 listener(...args)"]
CaptureResult1["捕获结果,handled = true"]
end
subgraph Phase2["阶段 2:自定义 eventHandlers[]"]
LoopHandlers["for (handler of this.eventHandlers)"]
CallHandler["customResult = handler(eventName, ...args)"]
CheckUndefined["customResult !== undefined?"]
Override["result = customResult
handled = true"]
end
subgraph Phase3["阶段 3:_emitHook(Vue emit)"]
CheckHook["this._emitHook 存在?"]
CallHook["this._emitHook(eventName, ...args, callback)"]
HookCallback["callback(customReturnValue)"]
OverrideHook["如果 customReturnValue !== undefined
result = customReturnValue"]
end
Return["return result"]
Start --> Phase1
Phase1 --> CheckEvent
CheckEvent --> FindListener
FindListener -->|"存在"| ExecDefault
ExecDefault --> CaptureResult1
CaptureResult1 --> Phase2
Phase2 --> LoopHandlers
LoopHandlers --> CallHandler
CallHandler --> CheckUndefined
CheckUndefined -->|"是"| Override
Override --> LoopHandlers
CheckUndefined -->|"否"| LoopHandlers
LoopHandlers -->|"完成"| Phase3
Phase3 --> CheckHook
CheckHook -->|"是"| CallHook
CallHook --> HookCallback
HookCallback --> OverrideHook
CheckHook -->|"否"| Return
OverrideHook --> Return处理器执行优先级:
defaultEventHandler()- 首先执行,检查this.listeners对象中是否存在匹配的事件名- 自定义
eventHandlers[]- 遍历通过addEventHandler()注册的RGEventHandler函数数组 _emitHook()- 框架集成点,通常供 Vue2/Vue3 用于$emit()
返回值逻辑:
- 第一个非
undefined的返回值成为最终result handled标记用于追踪是否有任何处理器被执行- 最终
result会向上传播回调用方代码 - 像
beforeZoomStart之类的事件使用返回值来取消操作(return false) - 像
onNodeDragging之类的事件使用返回值来覆盖坐标(return {x, y})
用户交互类别
relation-graph 系统支持一套全面的用户交互范围,按以下类别组织:
节点交互
- 点击 - 选择/取消选择节点,触发自定义操作
- 拖拽 - 移动节点,并在视口边缘自动滚动画布
- 调整大小 - 通过编辑控制器的手柄调整节点尺寸
- 展开/折叠 - 在层级布局中切换子节点可见性
- 右键菜单 - 右键点击显示自定义菜单
实现细节参见 User Actions & Interactions。
画布交互
- 拖拽 - 平移整个图视图
- 框选 - 绘制选择矩形以选择多个节点
- 缩放 - 鼠标滚轮或双指捏合缩放
- 点击 - 取消选中所有元素
连线交互
- 点击 - 选择连线进行编辑
- 拖拽控制点 - 调整正交折线路径(shapes 44/49)
- 拖拽文本 - 重新定位连线标签
- 创建 - 用于绘制新连接的多步骤向导
键盘交互
- 按键按下/抬起 - 用于自定义快捷键的通用键盘事件处理器
- 具备焦点感知的输入检测可防止干扰文本编辑
触摸支持
所有基于鼠标的交互都有对应的触摸等价形式:
- 单击 → 点击
- 拖拽 → 平移/移动
- 捏合 → 缩放
- 长按 → 右键菜单
系统会通过 isSupportTouch() 自动检测触摸能力,并相应地调整 UI(例如隐藏基于悬停的模板引导)。
事件流示例:节点拖拽操作
结合 RGDragUtils 的节点拖拽完整事件流
sequenceDiagram
participant User
participant RGNodePeel
participant With7Event as "RelationGraphWith7Event"
participant DragUtils as "RGDragUtils"
participant DataProvider as "dataProvider"
participant Base as "RelationGraphBase"
participant Listeners as "listeners: RGListeners"
User->>RGNodePeel: "@mousedown on node"
RGNodePeel->>With7Event: onNodeDragStart(node, e)
Note over With7Event: Check node.disableDrag
Check options.disableDragNode
Initialize _nodeXYMappingBeforeDrag
With7Event->>DragUtils: startDrag(e, nodeStartXY, dragEnd, draggingTick)
Note over DragUtils: document.addEventListener('mousemove')
document.addEventListener('mouseup')
loop requestAnimationFrame loop
User->>DragUtils: mousemove
DragUtils->>With7Event: draggingTick(offsetX, offsetY, ...)
alt First move > 4px
With7Event->>Base: emitEvent(RGEventNames.onNodeDragStart, node, e)
Base->>Listeners: listeners.onNodeDragStart?(node, e)
With7Event->>With7Event: this._canvasMovingTimer = requestAnimationFrame(movingLoop)
end
With7Event->>With7Event: draggingEvent = $draggingEvent
Note over With7Event: movingLoop() in requestAnimationFrame
With7Event->>With7Event: getCanvasXyByViewXy(draggingEventXy)
With7Event->>With7Event: Calculate newX, newY, buff_x, buff_y
With7Event->>Base: emitEvent(RGEventNames.onNodeDragging, node, newX, newY, buff_x, buff_y, e)
Base->>Listeners: listeners.onNodeDragging?(...)
Listeners-->>Base: return {x?, y?} or undefined
Base-->>With7Event: customPosition
alt customPosition returned
With7Event->>With7Event: Override newX/newY with customPosition
end
With7Event->>With7Event: canvasAutoMoving(draggingEventXy)
With7Event->>With7Event: draggingSelectedNodes(node, newX, newY, buff_x, buff_y)
With7Event->>DataProvider: updateNode(node.id, {x: newX, y: newY})
With7Event->>With7Event: _dataUpdated()
end
User->>DragUtils: mouseup
DragUtils->>With7Event: dragEnd(x_buff, y_buff, $dragEndEvent)
With7Event->>With7Event: cancelAnimationFrame(this._canvasMovingTimer)
With7Event->>DataProvider: updateOptions({draggingNodeId: ''})
alt dragStarted
With7Event->>Base: emitEvent(RGEventNames.onNodeDragEnd, node, e, x_buff, y_buff)
Base->>Listeners: listeners.onNodeDragEnd?(node, e, x_buff, y_buff)
else no drag (click)
With7Event->>With7Event: onNodeClick(node, e)
end
With7Event->>With7Event: _dataUpdated()实现细节:
| 方面 | 实现 |
|---|---|
| 拖拽判定 | 位移必须超过 Math.abs(offsetX) + Math.abs(offsetY) > 4,以区分点击与拖拽 |
| 节流 | requestAnimationFrame() 循环每帧仅处理最新的 draggingEvent(约 60fps) |
| 位置计算 | getCanvasXyByViewXy() 将视图坐标转换为画布坐标,并考虑缩放/偏移 |
| 自动滚动 | canvasAutoMoving() 检查光标是否在距视口边缘 40px 内,并据此平移画布 |
| 多节点拖拽 | draggingSelectedNodes() 将 editingController.nodes 中所有节点按相同的 buff_x、buff_y 位移 |
| 参考线 | updateReferenceLineView() 计算与附近节点的对齐关系,并返回吸附坐标 |
| 拖拽清理 | 清空 _nodeXYMappingBeforeDrag,重置 draggingNodeId,调用 _dataUpdated() |
状态追踪:
_nodeXYMappingBeforeDrag: {[nodeId:string]: {x, y}}- 保存被拖拽节点的原始位置_canvasMovingTimer: number- 拖拽循环的requestAnimationFrame()定时器 IDdraggingEvent: RGUserEvent- 最新鼠标/触摸事件,每帧处理一次dragStarted: boolean- 追踪位移是否超过阈值(4px)
事件取消与返回值
某些事件支持通过返回值实现取消或位置覆盖:
布尔返回 - 取消默认行为
返回 boolean | void 的事件允许通过返回 false 进行取消:
| 事件 | 被取消的默认行为 |
|---|---|
beforeZoomStart |
阻止缩放操作 |
beforeNodeResize |
阻止节点缩放 |
beforeCreateLine |
阻止连线创建 |
beforeScrollStart |
阻止画布滚动 |
示例:
onBeforeCreateLine: (lineInfo) => {
if (lineInfo.fromNode.id === lineInfo.toNode.id) {
console.log('Self-loops not allowed');
return false; // Cancel line creation
}
}
位置返回 - 覆盖坐标
返回 RGPosition | undefined 的拖拽事件允许自定义定位逻辑:
| 事件 | 位置覆盖效果 |
|---|---|
onNodeDragging |
拖拽期间覆盖计算得到的节点位置 |
onCanvasDragging |
平移期间覆盖计算得到的画布偏移 |
示例 - 吸附到网格:
onNodeDragging: (node, newX, newY, buffX, buffY, e) => {
const gridSize = 20;
return {
x: Math.round(newX / gridSize) * gridSize,
y: Math.round(newY / gridSize) * gridSize
};
}
系统会尊重返回的坐标,并使用它们替代计算值。
拖拽工具:RGDragUtils
RGDragUtils 模块提供一个统一的拖拽处理抽象,可同时适用于鼠标与触摸事件。
核心方法签名:
RGDragUtils.startDrag(
e: RGUserEvent,
basePosition: RGPosition,
dragEnd: (x_buff, y_buff, endEvent) => void,
dragging: (offsetX, offsetY, basePosition, startEventInfo, currentEvent) => void
)
工作流:
- 事件归一化 - 通过
isSupportTouch(e)检测鼠标/触摸,使用getClientCoordinate(e)提取坐标 - 监听器注册 - 在
document.body上绑定mousemove/touchmove与mouseup/touchend - Tick 回调 - 每次 move 事件都调用
dragging()回调并计算偏移 - 清理 - 调用
dragEnd()回调,在鼠标/触摸抬起时移除事件监听器
被用于:
onNodeDragStart()- 节点拖拽onCanvasDragStart()- 画布平移onResizeStart()- 通过编辑控制器调整节点大小onVisibleViewHandleDragStart()- 小视图视口拖拽
性能考量
使用 requestAnimationFrame 的事件节流
高频事件使用 requestAnimationFrame() 节流,将更新限制在显示器刷新率:
let draggingEvent: RGUserEvent;
const draggingCallback = () => {
if (!draggingEvent || dragStoped) return;
// Process draggingEvent...
};
const movingLoop = () => {
draggingCallback();
this._canvasMovingTimer = requestAnimationFrame(movingLoop);
};
this._canvasMovingTimer = requestAnimationFrame(movingLoop);
收益:
- 将
onNodeDragging与onCanvasDragging的发射限制在约 60fps - 防止 React/Vue 过度重渲染
- 每帧只处理最新事件
使用于:
onNodeDragStart()- packages/relation-graph-models/models/RelationGraphWith7Event.ts:136-170onResizeStart()- packages/relation-graph-models/models/RelationGraphWith91Editing.ts:179-185
触摸与鼠标检测
系统通过检测交互类型来为触摸设备优化 UI:
const isTouchEvent = isSupportTouch(e);
if (!isTouchEvent) {
this.dataProvider.updateOptions({
showTemplateNode: true // Show hover guide for mouse only
});
}
工具函数:
isSupportTouch(e: RGUserEvent): boolean- 检查事件是否为 TouchEventgetClientCoordinate(e: RGUserEvent): {clientX, clientY}- 从鼠标或触摸事件中提取坐标
防抖的布局重计算
批量节点展开/折叠操作使用防抖以避免重复布局:
private _relayoutTaskTimer;
private _effectWhenExpandedOrCollapsed(node: RGNode) {
if (this._relayoutTaskTimer) {
clearTimeout(this._relayoutTaskTimer);
}
this._relayoutTaskTimer = setTimeout(() => {
this.updateNodesVisibleProperty([node].concat(descendantNodes));
if (options.reLayoutWhenExpandedOrCollapsed) {
this.doLayout();
}
}, 100);
}
目的: 当 expandNode() 或 collapseNode() 在短时间内被多次调用时,仅在 100ms 延迟后执行一次布局。
总结
relation-graph 事件系统提供:
- 在所有框架(Vue、React、Svelte)中一致的统一事件模型
- 通过
RGEventNamesenum 与RGListenersinterface 实现的类型安全事件定义 - 通过声明式 props 或编程式处理器实现的灵活注册
- 用于自定义行为的事件取消与覆盖机制
- 支持中间件模式的多层处理器执行
- 具备自动检测与自适应的触摸与鼠标支持
- 包括节流与防抖在内的性能优化
关于事件分发器与处理器链路的架构细节,请继续阅读 Event System Architecture。关于实现用户交互的具体示例,请参见 User Actions & Interactions。