JavaScript is required

节点、连线与画布右键菜单

这个示例展示如何通过一个 relation-graph `onContextmenu` 处理器,为节点、连线和空白画布统一弹出悬浮菜单。它使用 `RGSlotOnView` 渲染覆盖层、`RGSlotOnNode` 渲染仅图标圆形节点,并懒加载全局 toast 层来确认占位动作而不修改图数据。

为节点、连线和画布构建共享上下文菜单

此示例构建了什么

这个示例构建了一个全高度 relation graph,并提供一个悬浮上下文菜单:当用户右键单击节点、连线或空白画布时,菜单会出现在指针所在位置。图本身保持小巧且只读:包含三个绿色圆形图标节点、两条带标签的连线,以及一张白色圆角菜单卡片;菜单卡片会根据点击目标切换标题和操作项。

用户可以打开针对不同目标的菜单、选择某一行操作,并通过点击其他区域关闭覆盖层。每个操作都会关闭菜单,并在右上角显示一条 toast,因此这个 demo 的重点是搭建上下文 UI 结构,而不是修改图本身。

核心思路是共享菜单状态模式:一个图事件处理器、一个覆盖层容器,以及面向三种目标类型分支渲染的内容。

数据是如何组织的

数据在 initializeGraph() 中以内联方式组装为一个 RGJsonData 对象,其中包含 rootId: '2'、三条节点记录和两条连线记录。每个节点都包含 idtextdata.myicondata.myicon 不只是装饰性元数据;它直接驱动自定义节点插槽,使每个节点都能渲染不同的 Lucide 图标。

在调用 setJsonData() 之前没有任何预处理步骤。组件直接在代码中构造最终的 relation-graph 数据负载,然后原样加载到图实例中。在真实应用里,同样的数据结构可以表示服务、设备、所有权关系、工作流步骤或依赖边,而 data.myicon 也可以替换为类型、状态或分类字段,以选择不同的自定义渲染器。

relation-graph 的使用方式

index.tsxRGProvider 包裹整个 demo,MyGraph.tsx 则通过 RGHooks.useGraphInstance() 访问当前激活的图实例。一个在挂载时执行的 useEffect() 会调用 initializeGraph(),加载内联数据集、将图移动到中心位置,并让其适配视口。

图配置刻意保持轻量。defaultNodeColor 设置节点的绿色填充,defaultNodeShape 使用圆形节点,defaultJunctionPoint 让连线附着在节点边界上。从审阅过的源码来看,这里没有定义显式布局,因此该图依赖 relation-graph 对这份数据负载的默认排布方式。

这个示例通过两个 relation-graph 插槽来实现。RGSlotOnNode 用圆形图标渲染器替换了常规节点主体,RGSlotOnView 则在图视图内部渲染悬浮上下文菜单。菜单位置直接取自 eventPositionOnView,因此覆盖层会跟随右键位置出现,而不需要额外基于包装元素的坐标换算。

主图事件是 onContextmenu。它会提供被点击的目标类型、存在时的目标对象,以及画布坐标和视图坐标。这个示例把 RGEventTargetType 和可选目标对象保存到 React state 中,然后基于该状态分支生成菜单标题和操作项。即使是 Edit NodeChange ColorAdd New NodeReset Zoom 这样的菜单项,在这里也不会调用图变更 API;它们只会触发反馈提示。

Toast 反馈是在 relation-graph 之外通过本地 SimpleGlobalMessage 辅助模块实现的。该辅助模块会按需把一个全局消息宿主挂载到 document.body 中,配套的消息样式则将 toast 堆叠固定在屏幕右上角。

关键交互

  • 右键单击节点会打开 Node Menu,其中包含 View DetailsEdit NodeDelete
  • 右键单击连线会打开 Line Menu,其中包含 View DetailsChange ColorDelete
  • 右键单击空白画布会打开 Canvas Menu,其中包含 View DetailsAdd New NodeReset Zoom
  • 点击任意菜单项都会显示成功 toast,并关闭菜单。
  • 点击菜单外部会关闭覆盖层,而点击卡片内部会阻止事件冒泡,从而让用户可以先选择菜单项。

关键代码片段

这个选项块说明,该示例的大部分视觉设置都依赖图组件的内置选项。

const graphOptions: RGOptions = {
    defaultNodeColor: 'rgba(66,187,66,1)',
    defaultNodeShape: RGNodeShape.circle,
    defaultJunctionPoint: RGJunctionPoint.border
};

这份内联数据集证明,图数据负载是直接在组件代码中组装的,并且携带了节点级别的图标元数据。

const myJsonData: RGJsonData = {
    rootId: '2',
    nodes: [
        { id: '1', text: 'Node-1', data: { myicon: 'star' } },
        { id: '2', text: 'Node-2', data: { myicon: 'settings' } },
        { id: '5', text: 'Node-5', data: { myicon: 'gift' } }
    ],
    lines: [
        { id: 'l1', from: '1', to: '2', text: 'Link 1' },
        { id: 'l2', from: '1', to: '5', text: 'Link 2' }
    ]
};

这段初始化流程展示了该 demo 所采用的完整图实例工作流程。

if (graphInstance) {
    await graphInstance.setJsonData(myJsonData);
    await graphInstance.moveToCenter();
    await graphInstance.zoomToFit();
}

这个处理器展示了单个 relation-graph 事件如何转化为共享菜单状态和一个相对于视图定位的覆盖层位置。

const onContextmenu = (
    e: RGUserEvent,
    objectType: RGEventTargetType,
    object: RGNode | RGLine | undefined,
    eventPositionOnCanvas: RGCoordinate,
    eventPositionOnView: RGCoordinate
) => {
    setMenuTarget({ type: objectType, data: object });
    setMenuPosition({ x: eventPositionOnView.x, y: eventPositionOnView.y });
    setIsShowMenu(true);
};

这个节点插槽说明,图标渲染来自节点数据,而不是静态标记。

<RGSlotOnNode>
    {({ node }: RGNodeSlotProps) => (
        <div className="c-my-rg-node">
            <NodeIcon name={node.data?.myicon} />
        </div>
    )}
</RGSlotOnNode>

这个视图插槽片段展示了一个悬浮卡片如何根据当前图目标动态调整标题和操作项。

<RGSlotOnView>
    {isShowMenu && (
        <div
            className="pointer-events-auto context-menu-panel"
            style={{ left: menuPosition.x, top: menuPosition.y }}
            onClick={(e) => e.stopPropagation()}
        >
            <div className="menu-header">
                {menuTarget?.type === 'node' && 'Node Menu'}
                {menuTarget?.type === 'line' && 'Line Menu'}
                {menuTarget?.type === 'canvas' && 'Canvas Menu'}
            </div>
            {/* ... menu items omitted ... */}
        </div>
    )}
</RGSlotOnView>

这个消息 API 片段证明,菜单操作是通过一个按需挂载的全局宿主来加入 toast 反馈,而不是去修改图本身。

showMessage: (messageObject: { duration?: number; message: string; type: string; }) => {
    messageManager.mount();
    setTimeout(() => {
        messageManager.myMessageUIRef?.addMessage(
            messageObject.type,
            messageObject.message,
            messageObject.duration
        );
    }, 0);
},

这个示例的独特之处

对比数据表明,这个示例最接近 node-menu-2node-line-tips-contentmenunode-tipsbuilt-in-slots,但它的教学重点比这些相邻示例更具体。与 node-menu-2 相比,这里复用了相同的图标节点和悬浮卡片视觉风格,但控制流从节点插槽中的 DOM 处理器切换到了 relation-graph 的图级 onContextmenu,因此一条事件路径就能同时覆盖节点、连线和空白画布。与 node-line-tips-contentmenu 相比,这里的范围则收缩为一个紧凑的上下文操作脚手架,而不是更广泛的提示和检查工作区。

这里不寻常的特征组合并不只是“在 RGSlotOnView 里放一个菜单”。对比和稀有性记录强调的是:以 RGEventTargetType 为键的共享菜单状态、直接基于 eventPositionOnView 的定位,以及通过全局 toast 层完成只读操作确认。这让该示例在团队需要先搭建一个可复用的分支型上下文菜单框架、之后再接入真实业务命令时尤其有用。

也需要避免夸大它的独特性。其他示例同样使用了 RGSlotOnView 和自定义右键覆盖层。这里真正突出的地方,是它用一个紧凑的图级模式在同一个菜单组件中覆盖节点、连线和画布,同时保持图本身不发生变化。

这种模式还能应用到哪里

这种模式非常适合拓扑查看器、基础设施地图、架构图、流程检查器以及关系型界面,在这些场景中,同一套上下文 UI 需要同时作用于节点、连线和空白区域。这个 demo 里的占位操作可以替换成真实命令,例如打开详情抽屉、创建相邻节点、编辑连接属性,或启动画布级创建流程。

它也适用于那些希望先提供轻量确认、再进入更深层实现的监控和审批工具。真正可复用的部分是事件契约和覆盖层结构:让 relation-graph 识别目标、在指针附近放置一个视图层菜单,并把具体的动作处理逻辑留给应用自身。