JavaScript is required

带右键菜单和本地保存的关系图编辑器

这个示例把 relation-graph 变成轻量浏览器内图编辑器,支持工具栏创建节点/连线、识别目标的右键删除、重布局控制与本地保存恢复。它还在同一工作区叠加共享画布设置和图片导出工具,适合作为可编辑图框架参考而非只读查看器。

构建一个带右键菜单和本地保存的轻量级图编辑器

这个示例构建了什么

这个示例构建的是一个全屏图编辑工作区,而不是一个固定的查看器。用户可以添加节点、创建连线、删除选中的对象、重新应用布局、让视口适配内容,并将当前图状态保存到浏览器本地存储中。一个悬浮的辅助窗口会持续提供说明、画布交互设置以及图片导出功能。

这个演示最有价值的部分,在于它如何把多种编辑行为整合进一个小巧的外壳里。工具栏、右键菜单以及已保存状态的恢复流程协同工作,让同一块画布既可以作为初始树形视图,也可以作为一个轻量级的浏览器内编辑器。

数据是如何组织的

初始图以内联的 staticJsonData 声明,其中包含 rootIdnodeslines。这个起始数据刻意保持通用:一个根节点、若干环绕节点,以及几条带标签的关系。这样示例的重点就放在编辑行为上,而不是某个特定领域的 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() 更新 wheelEventActiondragEventAction,并使用图片生成相关 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-summarizerline-vertex-on-node 相比,这个示例并不聚焦于类型化节点语义或端点放置控制。它强调的是一个与领域无关的编辑外壳,可以在几乎没有业务逻辑包裹的情况下,完成添加节点、连接节点、删除选中对象以及重新打开已保存编辑结果。与 map-worlduse-dagre-layout 相比,这里的悬浮叠加层服务于实时编辑和本地持久化,而不是回放一个预先准备好的场景,或把外部布局坐标写回去。

这个模式还适用于哪些场景

这种模式适合那些需要直接维护图结构、但又不想引入大型工作流系统的内部工具。例如依赖关系编辑、组织结构维护、基础设施拓扑清理、轻量级建模白板,以及知识图谱整理工具。在这些场景中,用户通常需要补充一个缺失节点、连接两个已有对象、删除一条无效关系,并将当前状态保存以便后续继续处理。

它也适合作为从只读 relation graph 迁移到更具交互性的编辑器时的过渡步骤。团队可以先从这个“工具栏 + 右键菜单”的外壳开始,等基础的添加、连接、删除和恢复流程验证完成后,再逐步扩展校验、更丰富的表单、远程持久化、权限控制或领域特定的节点模板。