带右键菜单和本地保存的关系图编辑器
这个示例把 relation-graph 变成轻量浏览器内图编辑器,支持工具栏创建节点/连线、识别目标的右键删除、重布局控制与本地保存恢复。它还在同一工作区叠加共享画布设置和图片导出工具,适合作为可编辑图框架参考而非只读查看器。
构建一个带右键菜单和本地保存的轻量级图编辑器
这个示例构建了什么
这个示例构建的是一个全屏图编辑工作区,而不是一个固定的查看器。用户可以添加节点、创建连线、删除选中的对象、重新应用布局、让视口适配内容,并将当前图状态保存到浏览器本地存储中。一个悬浮的辅助窗口会持续提供说明、画布交互设置以及图片导出功能。
这个演示最有价值的部分,在于它如何把多种编辑行为整合进一个小巧的外壳里。工具栏、右键菜单以及已保存状态的恢复流程协同工作,让同一块画布既可以作为初始树形视图,也可以作为一个轻量级的浏览器内编辑器。
数据是如何组织的
初始图以内联的 staticJsonData 声明,其中包含 rootId、nodes 和 lines。这个起始数据刻意保持通用:一个根节点、若干环绕节点,以及几条带标签的关系。这样示例的重点就放在编辑行为上,而不是某个特定领域的 schema。
在执行 setJsonData(...) 之前,代码会遍历默认的连线列表并在连线没有显式 id 时注入 line_auto_{index},以此规范化连线 ID。这个预处理很重要,因为后续的删除操作是通过 ID 删除连线的。初始化流程还会检查 rg-my-graph-app 这个本地存储键;如果存在已保存的图 JSON,就会用这个快照替换默认数据。
恢复分支会在加载数据之前调整布局行为。第一次加载使用树形布局,这样初始内容可以立即清晰可读;而恢复已保存快照时则切换为固定布局,以便保存下来的节点坐标在刷新后仍然有效。在真实应用里,同样的数据结构可以表示组织结构、依赖图、拓扑编辑器、轻量级建模工具,或知识图谱维护界面。
relation-graph 是如何使用的
RGProvider 包裹整个示例,使 relation-graph 的 hooks 能够解析当前活动实例。RelationGraph 接收一组树形布局选项:RGJunctionPoint.border、沿路径渲染的连线文字,以及显式设置的垂直和水平间距。这些默认值能生成清晰的初始排布,同时为后续编辑保留空间。
RGHooks.useGraphInstance() 是核心集成点。示例通过它使用 setJsonData(...) 加载 JSON,使用 updateOptions(...) 切换布局,执行居中和内容适配,在视图空间和画布空间之间转换指针坐标,通过 getGraphJsonData() 保存当前图,按 ID 删除节点和连线,以及在运行时创建新节点和新连线。工具栏还使用了 RGHooks.useViewInformation()、RGHooks.useCreatingNode() 和 RGHooks.useCreatingLine(),这样叠加层就能显示实时缩放百分比,并反映当前的编辑模式。
自定义图层 UI 通过 RGSlotOnView 挂载,从而让工具栏和临时右键菜单都位于图视图层内部。这一点很重要,因为右键菜单的位置来自 getViewXyByEvent(...),而在画布中插入对象时,又会通过 getCanvasXyByViewXy(...) 把已保存的菜单位置转换回去。共享的 DraggableWindow 组件提供了第二层叠加界面,用于显示说明和设置;在它内部,CanvasSettingsPanel 通过 RGHooks.useGraphStore() 更新 wheelEventAction 和 dragEventAction,并使用图片生成相关 API 导出准备好的画布。本地 SCSS 文件则让节点标签保持紧凑,并把被选中的连线高亮为橙色。
关键交互
- 点击
Save会从当前运行中的实例中序列化图 JSON,并将其存入浏览器本地存储。 - 在保存后重新加载,会以固定布局恢复已保存的图,从而复用之前的坐标,而不是重新计算。
- 点击
Add Node会启动交互式节点放置,然后在放下的位置插入一个带有随机尺寸的生成节点。 - 点击
Add Line会启动交互式连线创建;当用户选择源节点和目标节点时,工具栏也会显示相应提示。 - 在画布上右键会打开一个悬浮菜单,可以在点击位置添加节点,或启动与工具栏中相同的连线创建流程。
- 在节点或连线上右键会打开一个与目标对象相关的删除操作,并且只删除当前选中的对象。
- 点击
Fit Content会重新框定当前图内容,点击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}`
}))
};
const mySavedDataString = localStorage.getItem(myLocalDataItemKey);
const mySavedData = mySavedDataString ? (JSON.parse(mySavedDataString) as RGJsonData) : null;
if (mySavedData) {
graphInstance.updateOptions({ layout: { layoutName: 'fixed' } });
await graphInstance.setJsonData(mySavedData);
} else {
await graphInstance.setJsonData(myJsonData);
}
这一段展示了右键菜单处理函数如何同时捕获被点击目标的类型,以及用于放置叠加层的视图空间坐标。
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);
};
这一段展示了如何通过 startCreatingNodePlot(...)、生成节点 ID,以及按画布坐标插入节点,来实现运行时节点创建。
const startAddNode = (e: React.MouseEvent | React.TouchEvent) => {
const randomWidth = 140 + (Math.floor(Math.random() * 40) - 40);
const randomHeight = 30 + (Math.floor(Math.random() * 10) - 5);
const newNodeSize = { width: randomWidth, height: randomHeight };
graphInstance.startCreatingNodePlot(e.nativeEvent, {
templateNode: { text: 'New Node', color: '#ffffff', ...newNodeSize },
onCreateNode: (x, y) => {
addNodeOnCanvas({
x: x - newNodeSize.width / 2,
y: y - newNodeSize.height / 2
}, newNodeSize);
}
});
};
这一段展示了当用户完成交互式连线流程之后,如何在运行时创建连线。
const startAddLine = (e: React.MouseEvent | React.TouchEvent) => {
const newLineTemplate: JsonLineLike = { lineWidth: 2, color: '#cebf88ff', fontColor: '#cebf88ff', text: 'New Line' };
graphInstance.startCreatingLinePlot(e.nativeEvent, {
template: newLineTemplate,
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}` }]);
}
}
});
};
这一段展示了 RGSlotOnView 叠加层如何在画布创建操作和面向对象的删除操作之间分支。
<RGSlotOnView>
<MyGraphToolbar
onSaveButtonClick={onSave}
onAddNodeButtonClick={startAddNode}
onAddLineButtonClick={startAddLine}
/>
{showNodeTipsPanel && (
<div style={{ left: `${menuPos.x}px`, top: `${menuPos.y}px` }}>
{currentObj.type === 'canvas' && (
<button onClick={() => {
const xyOnCanvas = graphInstance.getCanvasXyByViewXy(menuPos);
addNodeOnCanvas(xyOnCanvas);
}}>
Add New Node
</button>
)}
{(currentObj.type === 'node' || currentObj.type === 'line') && (
<button onClick={handleDelete}>Delete {currentObj.type === 'node' ? 'Node' : 'Line'}</button>
)}
</div>
)}
</RGSlotOnView>
这一段展示了工具栏如何反映当前图状态,并将重新布局作为一个显式的图实例操作暴露出来。
const graphInstance = RGHooks.useGraphInstance();
const viewInfomation = RGHooks.useViewInformation();
const creatingLine = RGHooks.useCreatingLine();
const creatingNode = RGHooks.useCreatingNode();
const layoutGraph = () => {
graphInstance.updateOptions({
layout: {
layoutName: 'tree'
}
});
graphInstance.doLayout();
};
这一段展示了共享设置面板如何修改画布行为,并从准备好的图 DOM 中导出图片。
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();
};
这个示例的独特之处
对比数据表明,这个示例相比 create-object-from-menu 更像一个更完整的编辑器骨架。两个示例都在 RGSlotOnView 中使用了面向目标对象的右键菜单,但这个示例在此基础上进一步扩展了持久化工具栏、实时缩放读数、内容适配与重新布局控制,以及本地保存与恢复。因此,当团队需要的是一个可编辑工作区,而不仅仅是一个右键菜单变更模式时,它会是更强的起点。
另一个鲜明特点是它的持久化流程。示例一开始使用 relation-graph 的树形布局来提供清晰的默认排布,只有在检测到已保存的图 JSON 时才切换到固定布局。这种双布局顺序既保留了首次加载时干净的初始展示,也能在恢复编辑内容时保住已修改过的坐标。
与 amount-summarizer 和 line-vertex-on-node 相比,这个示例并不聚焦于类型化节点语义或端点放置控制。它强调的是一个与领域无关的编辑外壳,可以在几乎没有业务逻辑包裹的情况下,完成添加节点、连接节点、删除选中对象以及重新打开已保存编辑结果。与 map-world 或 use-dagre-layout 相比,这里的悬浮叠加层服务于实时编辑和本地持久化,而不是回放一个预先准备好的场景,或把外部布局坐标写回去。
这个模式还适用于哪些场景
这种模式适合那些需要直接维护图结构、但又不想引入大型工作流系统的内部工具。例如依赖关系编辑、组织结构维护、基础设施拓扑清理、轻量级建模白板,以及知识图谱整理工具。在这些场景中,用户通常需要补充一个缺失节点、连接两个已有对象、删除一条无效关系,并将当前状态保存以便后续继续处理。
它也适合作为从只读 relation graph 迁移到更具交互性的编辑器时的过渡步骤。团队可以先从这个“工具栏 + 右键菜单”的外壳开始,等基础的添加、连接、删除和恢复流程验证完成后,再逐步扩展校验、更丰富的表单、远程持久化、权限控制或领域特定的节点模板。