JavaScript is required

图形编辑工作台

这个示例把 relation-graph 打造成完整图形编辑工作台,包含创建工具、上下文节点/连线编辑、分组、容器节点、布局切换和本地历史。它非常适合作为需要“一体化编辑壳层”的团队参考,而非单功能窄示例。

使用 relation-graph 构建图形编辑工作台

这个示例构建了什么

这个示例构建的是一个全屏图形编辑器,而不是一个只读图查看器。画布外围包裹了顶部工具栏、底部创建面板、浮动节点和连线工具栏、右键菜单、布局对话框、可选标尺、自定义背景层,以及一个小地图开关。

用户可以基于模板创建新节点和连线,调整节点尺寸与样式,编辑连线几何形态和标记,对选中节点进行分组,把节点移动到容器节点中,切换布局策略,以及保存或重新打开本地版本。这里最重要的并不是某一个单独的控件,而是这个示例如何把 relation-graph 组合成一个文档式编辑界面,并让历史记录、混合连接目标和多种自定义节点渲染器协同工作。

数据如何组织

运行中的文档模型是显式定义的。MyGraphDocJson 存储四个顶层集合:nodeslinesfakeLinesmyGroups。这意味着编辑器不会把分组或混合目标连接当成临时 UI 状态处理,它们与普通节点和链接一样,都是同一份已保存文档的一部分。

容器归属通过节点上的 data.ownerContainerId 存储,容器节点则维护 data.containerNodesIds。分组成员关系单独存储在 myGroups 中,每个 group 都会在序列化结果里记录样式元数据以及成员节点 id。底部工具栏也会在任何元素被加入图中之前先定义可复用的节点和连线模板,因此创建过程是从结构化预设开始,而不是依赖临时性的变更。

在记录快照之前,编辑器会通过 initialDocData()loadDocVersion() 清空并重新加载整张图。这个重新加载路径会一起重建节点、普通连线、假连线和分组。第一条历史记录会有意延迟一小段时间写入,这样恢复后的节点尺寸可以先完成测量,再捕获快照。在真实产品中,同样的文档结构也可以表示工作流步骤、服务拓扑、AI 管道、制造工位或内部审批流程图。

relation-graph 是如何使用的

这个示例使用 RGProvideruseRelationGraph(),从编辑器外壳内部同时获取图实例和 RelationGraph 组件。图以固定布局启动,并启用面向编辑器的默认配置,例如隐藏内置工具栏、支持框选拖拽、滚轮平移,以及自定义的 RelationGraphPlusCore 子类。

relation-graph 的 slots 承担了大部分 UI 组合工作。RGSlotOnView 承载固定在编辑器层的界面元素,例如工具栏、对话框、上下文菜单、小地图按钮和可选标尺。RGSlotOnNode 切换节点渲染器,使同一张图可以显示普通节点、容器节点、CPU 风格引脚节点、带输入/输出端口的 AI 模型节点,以及自定义 BoBo 节点。RGSlotOnCanvas 则渲染会随着画布一起移动的动态分组覆盖层,而不是固定在视口上。

编辑器也大量依赖内置的编辑钩子和控制器。RGEditingLineControllerRGEditingReferenceLineRGEditingConnectControllerRGMiniToolBarRGMiniView 都直接挂载在场景中。创建流程来自 startCreatingNodePlot()startCreatingLinePlot(),而实例 API 则用于 createLayout()moveToCenter()zoomToFit()updateNode()updateLine()addNodes()addLines()addFakeLines()_clearGraph()_addNodes()_addLines()_addFakeLines()

布局支持范围也比普通 demo 更广。选择器提供 tree、center、simple-tree、force、folder、random、grid、flow、column-grid 和 force-directed 模式。flow 会把布局委托给基于 Dagre 的辅助方法,force-directed 会委托给基于 Sigma 的辅助方法,而 simple-tree 会被转换成标准的 relation-graph tree 布局,并设置 simpleTree = true

样式也在框架边界做了定制。这个示例把生成出的 CSS 变量注入到 document.head 中,用于复用的节点和连线样式类;定义了五种 SVG 连线标记;并通过 CSS 变量结合图缩放和画布偏移来驱动编辑器背景。

关键交互

点击节点和连线会切换编辑焦点,点击画布则会清除当前激活选择。矩形框选会把结果回写到图中,先将节点标记为已选中,再把这组选中状态提升到编辑控制器中。

底部工具栏会同时启动节点和连线的拖拽创建流程。新节点在创建过程中可以直接放入容器节点,已有的自由节点之后也可以拖到容器或分组上方,在真正提交归属前先显示放置反馈。

右键行为会根据目标类型变化。节点、连线、分组和空白画布都会打开各自独立的上下文菜单面板。这样一来,删除类或结构性操作就会贴近当前目标,而不是被埋进一个全局检查面板里。

连线创建支持混合目标。当两个端点都是普通节点时,新连接会作为普通连线保存;当目标是 group 或自定义端点这类非节点连接目标时,编辑器会把这条连接降级为假连线,并在已保存文档中保留对应的目标元数据。

撤销、重做、复制、粘贴、删除和本地保存,使这个示例更像一个轻量文档编辑器,而不是一个短暂的交互演示。UI 中可以直接访问快照历史,保存的版本则保存在浏览器本地存储里。

关键代码片段

这段代码展示了编辑器序列化和恢复时使用的文档结构。

export type MyGraphDocJson = {
    nodes: JsonNode[];
    lines: JsonLine[];
    fakeLines: JsonLine[];
    myGroups: MyNodesGroupJson[]
}

这段代码展示了图初始化与自定义 fake-line 目标解析以及本地历史恢复是如何关联在一起的。

const onReady = async(graphInstance: RelationGraphInstance) => {
    if (graphInstance) {
        myGraphActions.current.setGraphInstance(graphInstance);
        graphInstance.setFakeLineTargetRender(myGraphActions.current.getFakeLineTarget.bind(myGraphActions.current));
        await myGraphActions.current.onReady();
    }
    myGraphActions.current.setReactiveData(editorState);
    myGraphActions.current.addListeners();
    await loadLocalHistory();
};

这段代码展示了历史快照会把假连线和分组与普通图数据一起序列化。

const {nodes, lines} = this.getGraphJsonData();
const fakeLines: JsonLine[] = [];
for (const elLine of graphInstance.getFakeLines()) {
    const jsonLine = graphInstance.transRGLineToJsonObject(elLine);
    fakeLines.push(jsonLine);
}
const myGroups = this.editorState.myGroups.map(group => {
    return {
        ...group,
        groupNodes: group.groupNodes.map(node => node.id)
    };
});

这段代码展示了创建面板使用 relation-graph 的 plotting API,而不是手动放置 DOM 覆盖层。

graphInstance.startCreatingNodePlot(e, {
    templateText: tempNode.text,
    templateNode: newNodeTemplate,
    onCreateNode: (x, y, nodeTemplate) => {
        onMyNodeCreateFinish(x, y, nodeTemplate);
    }
});

这段代码展示了将节点到节点的连接保持为普通连线、并把混合目标连接降级为假连线的关键分支。

newLineJson.fromType = fromNode.targetType;
newLineJson.toType = toNode.targetType;
if (fromNode.targetType === RGInnerConnectTargetType.Node && toNode.targetType === RGInnerConnectTargetType.Node) {
    if (newLineJson.isFakeLine) {
        this.getGraphInstance().addFakeLines([newLineJson]);
    } else {
        this.getGraphInstance().addLines([newLineJson]);
    }
} else {

这段代码展示了某个自定义节点渲染器如何把 relation-graph connect targets 暴露为真实的编辑端口。

<RGConnectTarget
    junctionPoint={RGJunctionPoint.left}
    targetId={node.id + '-input-' + item.name}
    lineTemplate={{color: '#f8b817', lineWidth: 2}}
    disableDrag={disableEdit}
>
    <div className="bg-white w-4 h-4 rounded-full border border-gray-200 hover:bg-amber-300">
    </div>
</RGConnectTarget>

这个示例的独特之处

它的独特价值在于覆盖面广。

undo-redo-example 相比,这不是一个围绕小图展开、以历史记录为核心的示例。历史记录只是这个更大编辑器里的一个子系统,这个编辑器还同时处理分组、容器、假连线、多种节点类型、布局切换、本地保存版本以及按目标类型区分的上下文菜单。

change-line-pathchange-line-textcustomize-line-toolbar 相比,这个示例并不是单独讲解某一个连线编辑点。它把连线几何、文本、标记、动画、连接点和只读显示模式嵌入进了一个完整的创作工作区,而这个工作区同时还能创建和编辑节点、分组以及布局。

element-line-editelement-connect-to-node 相比,它把 fake-line 与 connect-target 机制作为持续创作流程的一部分来使用,而不是作为预先准备好的展示。用户可以从编辑器面板发起一条新的连接,而生成的文档会在同一个快照模型中同时保留普通连线和混合目标假连线。

对比数据还支持一个更宽泛的结论:这个示例之所以少见,是因为它在同一个画布外壳中组合了基于面板的创建、上下文工具栏、批量选择、分组、容器归属逻辑、布局重排、小地图工具、自定义节点端口、自定义标记以及本地优先的文档历史。因此,与其把它看作一个“用于小流程编辑的工具”,不如把它视为一个可复用的编辑器参考实现。

这种模式还适用于哪里

这种模式可以适配工作流构建器,在这类场景里,用户需要在同一个画布中创建并修改步骤、决策点和分支连接。它也适用于内部架构编辑器、AI 管道设计器,以及那些某些端点是完整节点、另一些端点则是类型化端口或分组区域的制图工具。

同样的结构也适用于那些在后端持久化能力到位之前,就需要本地草稿能力的领域编辑器。团队可以先从本地快照历史、模板驱动的创建方式和自定义节点 slots 起步,之后再把保存文档的数据源替换成服务端存储,而无需改变编辑器的基础图模型。