右键菜单编辑节点与连线
这个示例在一个全屏白色工作区中构建了一张小型树形图,并在其上添加了一个自定义右键菜单。用户可以在空白画布区域右键以添加节点,在现有节点上右键以开始创建新的连线,也可以在节点或连线上右键以删除该特定对象。一个浮动辅助窗口会一直可用,用于重新布局、调整画布设置以及导出图片。
树形图中的右键菜单节点与连线编辑
这个示例构建了什么
这个示例在一个全屏白色工作区中构建了一张小型树形图,并在其上添加了一个自定义右键菜单。用户可以在空白画布区域右键以添加节点,在现有节点上右键以开始创建新的连线,也可以在节点或连线上右键以删除该特定对象。一个浮动辅助窗口会一直可用,用于重新布局、调整画布设置以及导出图片。
这个演示的重点并不在于初始数据集本身。真正有价值的是这套编辑流程:一个临时菜单即可处理与对象类型相关的操作,而不需要引入完整的工具栏式编辑器外壳,也不需要更大的持久化层。
数据是如何组织的
初始图数据以内联方式声明为 staticJsonData。它使用标准的 relation-graph JSON 结构,包括 rootId、nodes 和 lines,并且初始内容刻意保持得很小:一个根节点、若干围绕它的节点,以及三条带标签的关系。
在执行 setJsonData 之前,示例会先进行一个重要的预处理步骤。它会遍历初始连线,并为尚未包含 id 的连线补上一个 id。这个标准化处理很重要,因为后续菜单操作会按 ID 删除连线。初始化完成后,新节点和新连线会直接创建到当前图实例中,而不是通过重建整个 JSON 载荷来实现。
在真实应用中,同样的数据结构可以表示组织关系、所有权关系、依赖关系图、调查看板,或轻量级的资产维护关系图。这个示例刻意保持载荷的通用性,以便这种交互模式更容易复用。
relation-graph 是如何使用的
RGProvider 包裹整个页面,以便 relation-graph 的 hooks 能解析出当前激活的图实例。RelationGraph 被配置为树形布局,并启用了 RGJunctionPoint.border、defaultLineTextOnPath: true 以及显式的水平和垂直间距。这些设置让初始图保持紧凑,同时也为运行时新增节点和菜单触发的重新布局预留了足够空间。
RGHooks.useGraphInstance() 是核心集成点。演示通过它使用 setJsonData 加载数据,调用 moveToCenter() 和 zoomToFit() 让图居中显示,使用 getViewXyByEvent(...) 将浏览器事件转换为图视图坐标,使用 getCanvasXyByViewXy(...) 将已保存的菜单位置转换回画布坐标,通过 generateNewNodeId() 和 generateNewUUID(...) 创建 ID,借助 addNodes、addLines、removeNodeById 和 removeLineById 修改图数据,并通过 updateOptions(...) 加上 doLayout() 重新执行布局。
自定义菜单通过 RGSlotOnView 渲染,因此它属于图的覆盖层,而不是一个独立的页面级弹窗。onContextmenu 处理器会保存被点击目标的类型以及菜单位置,然后由 slot 在画布、节点和连线操作之间分支。这就是为什么同一个右键入口既可以创建节点,也可以启动交互式连线绘制,或者删除当前选中的对象。
页面还复用了共享的 DraggableWindow 子组件。在这个示例中,它提供操作说明、一个 Organize Layout 按钮、一个由 RGHooks.useGraphStore() 驱动的设置面板,以及通过 prepareForImageGeneration() 和 restoreAfterImageGeneration() 实现的图片导出功能。本地 SCSS 文件则添加了更小的节点标签字号,以及用于选中连线的橙色高亮样式。
关键交互
- 在画布上右键会在指针位置打开一个浮动菜单,并在对应的画布坐标插入一个新节点。
- 在节点上右键会打开一个节点专用菜单操作,进而启动
startCreatingLinePlot(...),让用户为新关系选择目标节点。 - 在节点或连线上右键会显示一个定向删除操作,只从当前图实例中移除那个对象。
- 点击其他位置会通过一个临时的全局点击监听器关闭菜单。
- 点击
Organize Layout会重新应用树形布局,并在图编辑后再次执行doLayout()。 - 打开辅助窗口设置后,用户可以修改滚轮和拖拽行为,并将当前图导出为图片。
关键代码片段
这段代码展示了定义基础工作区的树形布局和默认图配置。
const graphOptions: RGOptions = {
debug: false,
defaultJunctionPoint: RGJunctionPoint.border,
defaultExpandHolderPosition: 'right',
defaultLineTextOnPath: true,
layout: {
layoutName: 'tree',
treeNodeGapV: 20,
treeNodeGapH: 150
}
};
这段代码展示了在图加载前对连线 ID 进行标准化的初始化步骤。
const myJsonData: RGJsonData = {
...staticJsonData,
lines: staticJsonData.lines.map((line, index) => ({
...line,
id: line.id || `line_auto_${index}`
}))
};
await graphInstance.setJsonData(myJsonData);
graphInstance.moveToCenter();
graphInstance.zoomToFit();
这段代码展示了右键处理器如何同时捕获被点击对象的类型,以及用于放置菜单的视图空间位置。
const onContextmenu = (e: RGUserEvent, objectType: string, object: any) => {
const xyOnGraphView = graphInstance.getViewXyByEvent(e);
setMenuPos(xyOnGraphView);
setCurrentObj({ type: objectType, data: object });
setShowNodeTipsPanel(true);
const hideMenu = () => {
setShowNodeTipsPanel(false);
window.removeEventListener('click', hideMenu);
};
window.addEventListener('click', hideMenu);
};
这段代码展示了画布插入流程如何在把已保存的菜单位置从视图坐标转换回画布坐标后再使用它。
{currentObj.type === 'canvas' && (
<button
className="flex items-center h-8 pl-2 hover:bg-green-100 text-sm text-gray-700 rounded transition-colors text-left"
onClick={() => {
const xyOnCanvas = graphInstance.getCanvasXyByViewXy(menuPos);
addNodeOnCanvas(xyOnCanvas);
}}
>
Add New Node
</button>
)}
这段代码展示了从节点开始的连线创建流程,包括在选中目标节点时运行时生成连线 ID。
const startAddLineFromNode = (e: React.MouseEvent | React.TouchEvent) => {
const newLineTemplate: JsonLineLike = { lineWidth: 2, color: '#cebf88ff', fontColor: '#cebf88ff', text: 'New Line' };
graphInstance.startCreatingLinePlot(e.nativeEvent, {
template: newLineTemplate,
fromNode: currentObj.data,
onCreateLine: (from, to) => {
if (to && 'id' in to) {
const lineId = graphInstance.generateNewUUID(5);
graphInstance.addLines([{ id: lineId, ...newLineTemplate, from: from.id, to: to.id, text: `New Line-${lineId}` }]);
}
}
});
};
这段代码展示了同一个菜单如何分支到针对节点和连线的定向删除。
const handleDelete = () => {
if (currentObj.type === 'node') {
graphInstance.removeNodeById(currentObj.data.id);
} else if (currentObj.type === 'line') {
graphInstance.removeLineById(currentObj.data.id);
}
};
这段代码展示了辅助窗口如何在图编辑后重新应用布局。
const layoutGraph = () => {
graphInstance.updateOptions({
layout: {
layoutName: 'tree'
}
});
graphInstance.doLayout();
};
这段代码展示了共享设置面板如何读取图状态,并将准备好的画布导出为图片。
const graphInstance = RGHooks.useGraphInstance();
const { options } = RGHooks.useGraphStore();
const dragMode = options.dragEventAction;
const wheelMode = options.wheelEventAction;
const downloadImage = async () => {
const canvasDom = await graphInstance.prepareForImageGeneration();
let graphBackgroundColor = graphInstance.getOptions().backgroundColor;
if (!graphBackgroundColor || graphBackgroundColor === 'transparent') {
graphBackgroundColor = '#ffffff';
}
const imageBlob = await domToImageByModernScreenshot(canvasDom, {
backgroundColor: graphBackgroundColor
});
if (imageBlob) {
downloadBlob(imageBlob, 'my-image-name');
}
await graphInstance.restoreAfterImageGeneration();
};
这个示例的独特之处
从对比信息来看,这个示例是一个聚焦右键编辑的参考实现,而不是通用编辑器演示。它最鲜明的特点在于,一个 onContextmenu 入口同时覆盖了这个工作流中最关键的三类目标:画布、节点和连线。这意味着同一个临时覆盖层就能完成添加节点、发起新连接和删除选中对象,而不需要切换工具。
与 node-menu 相比,这个演示将上下文菜单用于真正的图数据修改,而不是只做只读反馈操作。与 my-graph-app 相比,它的范围明显更窄:它保留了相同的核心右键机制,但去掉了持久化和更完整的工具栏,因此这种菜单模式更容易被提取复用。与 amount-summarizer、relayout-after-add-nodes 和 line-vertex-on-node 相比,这里的重点并不是带类型的节点编辑、由应用主导的树重建,或高级端点控制。这里真正强调的是一个紧凑、能感知目标类型的菜单,在一个小型树形工作区中同时覆盖添加、连接、删除和重新布局。
另一个实用细节是坐标的分离。菜单定位使用的是图视图空间坐标,但新节点会在转换后以画布空间坐标创建。再结合初始化时的连线 ID 标准化,以及共享的浮动辅助窗口,这个示例形成了一种很有辨识度的组合:既具备轻量编辑行为,也包含实用的维护工具。
这种模式还能用在哪里
这种模式非常适合内部工具场景,在这些场景里,用户只需要少量直接编写操作,而不需要完整的工作流编辑器。例如组织结构维护视图、设备或依赖关系清理工具、知识图谱整理、事件调查看板,以及拓扑排查界面。在这些场景中,人们通常只需要补一个缺失节点、连接两个已有对象、移除一条无效关系,并快速重新执行布局。
当团队当前只有只读 relation-graph,并希望分阶段引入有限编辑能力时,这种模式也可以作为一个过渡方案。后续同样的方法还可以逐步扩展到校验、持久化、属性表单或基于角色的权限控制,但这个示例刻意把可复用的核心保持得很小:将上下文感知的右键操作直接挂接到当前图实例上。