JavaScript is required

用户交互与事件

目的与范围

本页介绍 relation-graph 中的事件系统架构,说明用户交互如何被捕获、处理,并传播到应用代码中。该系统提供一个统一的事件模型,可在所有受支持的框架(Vue、React、Svelte)中保持一致的工作方式。

关于特定事件类型与内部事件分发器架构的详细信息,请参见 Event System Architecture。关于处理点击、拖拽、手势等用户操作的具体示例,请参见 User Actions & Interactions


事件系统概览

relation-graph 事件系统遵循一种分层事件流模式:用户交互在 DOM 层被捕获,经由核心图逻辑处理,最终向应用层事件处理器发射。该架构将以下关注点分离:

  1. 用户操作捕获 - 绑定在图元素(节点、线、画布)上的 DOM 事件监听器
  2. 核心处理 - RelationGraphWith7Event 及相关类中的业务逻辑
  3. 事件发射 - 通过 emitEvent() 进行标准化事件广播
  4. 应用处理器 - 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

事件处理链:

  1. DOM 事件捕获 - 框架组件绑定原生事件监听器(@mousedown@click@wheel
  2. 处理方法调用 - 组件调用 RelationGraphWith7Event 上对应的方法(例如 onNodeDragStart()
  3. 业务逻辑执行 - 处理方法处理交互、更新 dataProvider、检查配置项
  4. 事件发射 - 处理方法调用 this.emitEvent(RGEventNames.onNodeDragStart, node, e)
  5. 多层分发 - emitEvent() 按顺序调用处理器:
    • defaultEventHandler() → 检查 this.listeners
    • 自定义 eventHandlers[] → 用户注册的中间件
    • _emitHook() → 框架特定的 emit(Vue $emit
  6. 应用回调 - 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 取消默认行为(例如 beforeNodeResizebeforeCreateLine
  • RGPosition | undefined - 返回坐标覆盖计算后的位置(例如 onNodeDraggingonCanvasDragging
  • 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.onNodeDragStart
RGEventNames.onNodeDragging
RGEventNames.onNodeDragEnd
点击连线 @click RGLinePath onLineClick(line, e) RGEventNames.onLineClick
点击画布 @mousedown RGCanvas onCanvasClick(e) RGEventNames.onCanvasClick
拖拽画布 @mousedown + mousemove RGCanvas onCanvasDragStart(e) RGEventNames.onCanvasDragStart
RGEventNames.onCanvasDragging
RGEventNames.onCanvasDragEnd
鼠标滚轮 @wheel RGCanvas onMouseWheel(e) RGEventNames.beforeZoomStart
RGEventNames.onZoomEnd
展开/折叠 @click 展开按钮 expandOrCollapseNode(node, e) RGEventNames.onNodeExpand
RGEventNames.onNodeCollapse
调整节点大小 @mousedown RGEditingNodeController onResizeStart(type, e) RGEventNames.onResizeStart
RGEventNames.beforeNodeResize
RGEventNames.onResizeEnd
右键菜单 @contextmenu 多处 onContextMenu(e, type, obj) RGEventNames.onContextmenu
键盘 @keydown / @keyup Document onKeyDown(e) / onKeyUp(e) RGEventNames.onKeyboardDown
RGEventNames.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

处理器执行优先级:

  1. defaultEventHandler() - 首先执行,检查 this.listeners 对象中是否存在匹配的事件名
  2. 自定义 eventHandlers[] - 遍历通过 addEventHandler() 注册的 RGEventHandler 函数数组
  3. _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_xbuff_y 位移
参考线 updateReferenceLineView() 计算与附近节点的对齐关系,并返回吸附坐标
拖拽清理 清空 _nodeXYMappingBeforeDrag,重置 draggingNodeId,调用 _dataUpdated()

状态追踪:

  • _nodeXYMappingBeforeDrag: {[nodeId:string]: {x, y}} - 保存被拖拽节点的原始位置
  • _canvasMovingTimer: number - 拖拽循环的 requestAnimationFrame() 定时器 ID
  • draggingEvent: 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
)

工作流:

  1. 事件归一化 - 通过 isSupportTouch(e) 检测鼠标/触摸,使用 getClientCoordinate(e) 提取坐标
  2. 监听器注册 - 在 document.body 上绑定 mousemove/touchmovemouseup/touchend
  3. Tick 回调 - 每次 move 事件都调用 dragging() 回调并计算偏移
  4. 清理 - 调用 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);

收益:

  • onNodeDraggingonCanvasDragging 的发射限制在约 60fps
  • 防止 React/Vue 过度重渲染
  • 每帧只处理最新事件

使用于:

  • onNodeDragStart() - packages/relation-graph-models/models/RelationGraphWith7Event.ts:136-170
  • onResizeStart() - 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 - 检查事件是否为 TouchEvent
  • getClientCoordinate(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)中一致的统一事件模型
  • 通过 RGEventNames enum 与 RGListeners interface 实现的类型安全事件定义
  • 通过声明式 props 或编程式处理器实现的灵活注册
  • 用于自定义行为的事件取消与覆盖机制
  • 支持中间件模式的多层处理器执行
  • 具备自动检测与自适应的触摸与鼠标支持
  • 包括节流与防抖在内的性能优化

关于事件分发器与处理器链路的架构细节,请继续阅读 Event System Architecture。关于实现用户交互的具体示例,请参见 User Actions & Interactions