Rete.js 实现可视化图编辑器

介绍

Introduction – Rete.js

Rete.js(读作 /ˈriː.ti/,在意大利语中意为“网络”)是一个用于创建可视化界面和工作流的框架。

它主要提供两方面能力:
可视化
Rete.js 支持使用多种前端框架来渲染编辑器,包括 React、Vue、Angular、Svelte 和 Lit
开发者可以为节点、端口、控件和连线创建自定义组件,不同框架的组件甚至可以在同一个编辑器中同时使用。

图处理(执行)
Rete.js 提供多种用于处理图结构的引擎,支持基于 数据流(Dataflow)控制流(Control Flow) 的执行方式。
不同类型的处理引擎可以在同一个图中组合使用。

在此之前我对比了其他相关框架,例如 React Flow、AntV G6 等,但是并不适合我项目需要的场景。

快速开始

初始化项目

首先用 vite 或者其他工具初始化一个 React 项目,如果你不想用 React,可以参照官方文档 https://retejs.org/docs/getting-startedhttps://retejs.org/docs/getting-started 创建其他框架的 Rete.js 项目。

然后添加相关依赖:

npm i rete rete-area-plugin rete-connection-plugin rete-render-utils rete-react-plugin

可选配置(Vite)

如果你用的是 Vite 可以尝试下面的配置,引入 Tailwind CSS 并配置 Ailas

引入 Tailwind CSS Vite 依赖:

npm install tailwindcss @tailwindcss/vite

然后配置 vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite';
import path from "path";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

然后在 index.css 或其他顶级 css 文件中引入 @import "tailwindcss";

最后再修改一下 tsconfig.ts

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

这样就可以在项目中使用 @/ 作为 src 路径了。

基本概念

在开始之前,需要先介绍一下 Rete 框架绘图的几个基本概念:

  1. 节点(Node):图中的不同节点
  2. 插槽(Socket):节点上输入/输出的端点
  3. 连接(Connection):连接节点之间插槽的连线

而 Rete 框架支持自定义上述类型,因此提供了一个名为 GetSchemes 的类型来表示整个绘图系统的样式。

export type GetSchemes<NodeData extends NodeBase, ConnectionData extends ConnectionBase> = {
    Node: NodeData;
    Connection: ConnectionData;
};

从这个类型可以看出,框架允许我们自己拓展节点、连接的子类,这样就能保证框架中绘制出的元素的数据类型(Node、Socket、Connection)也能同时满足我们业务的需要。

简单的例子

导入模块

import { createRoot } from "react-dom/client";
import { NodeEditor, type GetSchemes, ClassicPreset } from "rete";
import { AreaPlugin, AreaExtensions } from "rete-area-plugin";
import {
  ConnectionPlugin,
  Presets as ConnectionPresets,
} from "rete-connection-plugin";
import {ReactPlugin, Presets, type ReactArea2D, useRete} from "rete-react-plugin";

定义基本 Scheme

type Schemes = GetSchemes<
  ClassicPreset.Node,
  ClassicPreset.Connection<ClassicPreset.Node, ClassicPreset.Node>
>;
type AreaExtra = ReactArea2D<Schemes>;

创建编辑器

async function createEditor(container: HTMLElement) {
  // 定义插槽类型
  const socket = new ClassicPreset.Socket("socket");
  
  const editor = new NodeEditor<Schemes>();
  const area = new AreaPlugin<Schemes, AreaExtra>(container);

  // 节点连接器
  const connection = new ConnectionPlugin<Schemes, AreaExtra>();

  // 节点渲染器
  const render = new ReactPlugin<Schemes, AreaExtra>({ createRoot });

  // 节点选择
  AreaExtensions.selectableNodes(area, AreaExtensions.selector(), {
    accumulating: AreaExtensions.accumulateOnCtrl(),
  });

  // 配置节点渲染器
  render.addPreset(Presets.classic.setup());

  // 配置节点连接器
  connection.addPreset(ConnectionPresets.classic.setup());

  editor.use(area);
  area.use(connection);
  area.use(render);

  AreaExtensions.simpleNodesOrder(area);

  // 创建节点 A
  const a = new ClassicPreset.Node("A");
  a.addControl("a", new ClassicPreset.InputControl("text", { initial: "a" }));
  a.addOutput("a", new ClassicPreset.Output(socket));
  await editor.addNode(a);
  
  // 创建节点 B
  const b = new ClassicPreset.Node("B");
  b.addControl("b", new ClassicPreset.InputControl("text", { initial: "b" }));
  b.addInput("b", new ClassicPreset.Input(socket));
  await editor.addNode(b);

  // 将 A 节点的插槽 a 连接到 B 节点的插槽 b
  await editor.addConnection(new ClassicPreset.Connection(a, "a", b, "b"));

  // 移动节点的位置
  await area.translate(a.id, { x: 0, y: 0 });
  await area.translate(b.id, { x: 270, y: 0 });

  setTimeout(() => {
    // wait until nodes rendered because they dont have predefined width and height
    AreaExtensions.zoomAt(area, editor.getNodes());
  }, 10);
  return {
    destroy: () => area.destroy(),
  };
}

App 组件

function App() {
  const [ref] = useRete(createEditor);

  return (
    <>
      <div>
        <div ref={ref} style={{ height: "100vh", width: "100vw" }}></div>
      </div>
    </>
  )
}

运行测试

现在可以 npm run dev 把项目运行起来看一下效果:

封装 Rete.js

通过上面简单的例子发现,要绘制出节点与连线并不难,而是如何在此基础上进行拓展。

定义插槽

IGraphSocket.ts

import {ClassicPreset} from "rete";

export interface IGraphSocket {
  isCompatibleWith(socket: ClassicPreset.Socket): boolean;
}

BaseGraphSocket.ts

import {ClassicPreset} from "rete";
import type {IGraphSocket} from "./IGraphSocket.ts";

export abstract class BaseGraphSocket extends ClassicPreset.Socket implements IGraphSocket {
  protected constructor(socketId: string) {
    super(socketId);
  }

  abstract isCompatibleWith(socket: ClassicPreset.Socket): boolean
}

这里的 BaseGraphSocket 将作为后续开发自定义插槽的基类,不再使用 ClassicPreset.Socket 类。

socket-utils.ts

import {ClassicPreset, NodeEditor} from "rete";
import type {BaseGraphSchemes} from "../types/schemes.ts";
import type {BaseGraphSocket} from "../socket/BaseGraphSocket.ts";
import {BaseGraphNode} from "../node/BaseGraphNode.ts";
import {BaseGraphNodeConnection} from "../types/connection.ts";

type Input<S extends BaseGraphSocket> = ClassicPreset.Input<S>;
type Output<S extends BaseGraphSocket> = ClassicPreset.Output<S>;

/**
 * 从某个连接获取相关源节点与目标节点上的插槽类型
 *
 * @param editor NodeEditor
 * @param connection
 */
export function getConnectionSockets<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>,
  SCHEMES extends BaseGraphSchemes<S, N, C>
>(
  editor: NodeEditor<SCHEMES>,
  connection: SCHEMES["Connection"]
): { source?: S, target?: S } {
  const source = editor.getNode(connection.source);
  const target = editor.getNode(connection.target);

  const output =
    source &&
    (source.outputs as Record<string, Output<S>>)[connection.sourceOutput];
  const input =
    target && (target.inputs as Record<string, Input<S>>)[connection.targetInput];

  return {
    source: output?.socket,
    target: input?.socket
  };
}

定义节点

BaseGraphNode.ts

import {ClassicPreset} from "rete";
import {BaseGraphSocket} from "../socket/BaseGraphSocket.ts";

export abstract class BaseGraphNode<S extends BaseGraphSocket> extends ClassicPreset.Node<
  Record<string, NonNullable<S>>,
  Record<string, NonNullable<S>>,
  object
> {
  protected constructor(nodeType: string) {
    super(nodeType);
  }

  public addInputSocket(label: string, socket: S, displayName?: string) {
    this.addInput(label, new ClassicPreset.Input<S>(socket, displayName))
  }

  public removeInputSocket(label: string) {
    for (const input in this.inputs) {
      if (input == label) {
        this.removeInput(input);
      }
    }
  }
  
  public clearInputs() {
    for (const input in this.inputs) {
      this.removeInput(input);
    }
  }

  public addOutputSocket(label: string, socket: S, displayName?: string) {
    this.addOutput(label, new ClassicPreset.Output<S>(socket, displayName))
  }

  public removeOutputSocket(label: string) {
    for (const output in this.outputs) {
      if (output == label) {
        this.removeOutput(output);
      }
    }
  }
  
  public clearOutputs() {
    for (const output in this.outputs) {
      this.removeOutput(output);
    }
  }
}

这里的 BaseGraphNode 同上,替换原来的 ClassicPreset.Node 类。

定义 Schemes

connections.ts

import {ClassicPreset} from "rete";
import type {BaseGraphNode} from "../node/BaseGraphNode.ts";
import type {BaseGraphSocket} from "../socket/BaseGraphSocket.ts";

export class BaseGraphNodeConnection<
  S extends BaseGraphSocket,
  A extends BaseGraphNode<S>,
  B extends BaseGraphNode<S>
> extends ClassicPreset.Connection<A, B> {}

schemes.ts

import type {BaseGraphNode} from "../node/BaseGraphNode.ts";
import {type GetSchemes} from "rete";
import {BaseGraphNodeConnection} from "./connection.ts";
import type {BaseGraphSocket} from "../socket/BaseGraphSocket.ts";

export type BaseGraphSchemes<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>
> = GetSchemes<N, C>;

配置连接器

连接器顾名思义就是用来连接节点之间插槽的组件,在 Rete 里作为一个插件存在,名为:ConnectionPlugin

如果要连接插槽或阻止非法连接,就需要通过配置连接器来实现。

下面是具体的实现方法:

connection.addPreset(() => {
  return new ClassicFlow({
    // 判断两个插槽是否可以连接
    canMakeConnection(from: SocketData, to: SocketData) {
      const [source, target] = getSourceTarget(from, to) || [null, null];
      if (!source || !target || from === to) return false;

      const sourceNode = editor.getNode(source.nodeId)!;
      const targetNode = editor.getNode(target.nodeId)!;

      const sockets = getConnectionSockets(
        editor,
        props.connectionFactory(
          sourceNode,
          source.key,
          targetNode,
          target.key
        )
      );

      if (!sockets.source!.isCompatibleWith(sockets.target!)) {
        props.events?.onInvalidConnection?.();
        connection.drop();
        return false;
      }

      const connected = editor
        .getConnections()
        .find((conn) => conn.source == sourceNode.id &&
          conn.target == targetNode.id &&
          conn.sourceOutput == source.key &&
          conn.targetInput == target.key
        ) != null;

      // Already connected before
      if (connected) {
        connection.drop();
        return false;
      }
      return Boolean(source && target);
    },
    // 在 canMakeConnection 校验通过后调用此方法建立连接
    makeConnection(from: SocketData, to: SocketData, context) {
      const [source, target] = getSourceTarget(from, to) || [null, null];
      const { editor } = context;
      if (source && target) {
        void editor.addConnection(
          props.connectionFactory(
            editor.getNode(source.nodeId)!,
            source.key,
            editor.getNode(target.nodeId)!,
            target.key
          )
        );
        return true;
      }
    }
  });
});

配置渲染器

如果想要自定义绘制元素的外观,就不能再使用 render.addPreset(Presets.classic.setup()); 提供的默认配置,和上面的连接器一样,需要自定义配置:

// 配置渲染器
render.addPreset(
  Presets.classic.setup({
    customize: {
      node(data) {
        return ({ emit }) => {
          return props.render?.node(data.payload, emit) ?? <Presets.classic.Node data={data.payload} emit={emit} />
        }
      },
      connection(data) {
        // const { source, target } = getConnectionSockets(editor, data.payload);
        return () => {
          return props.render?.connection(data.payload) ?? <Presets.classic.Connection data={data.payload} />
        };
      },
      socket(data) {
        return () => {
          return props.render?.socket(data.payload as S) ?? <Presets.classic.Socket data={data.payload} />
        }
      }
    },
  })
);

这里只是给出一个示例,实际上返回的是一个 JSX.Element,你可以改成自己想要的样式,而 data.payload 对应的是 Schemes 中的类型,例如在 node 中的 data.payload 的类型就是 N extends BaseGraphNode,以此类推。

封装编辑器

接下来就是最重要的部分了,把原来的 createEditor 封装一下,对外暴露编辑器内的插件,同时支持从参数配置内部插件。

CreateGraphEditorProps 作为 createEditor 的参数,对外提供自定义渲染、事件监听的能力:

export interface CreateGraphEditorProps<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>,
  SCHEMES extends BaseGraphSchemes<S, N, C>
> {
  connectionFactory: (fromNode: N, fromSocket: string, toNode: N, toSocket: string) => SCHEMES["Connection"],
  render?: {
    node?: (node: SCHEMES["Node"], emit: RenderEmit<SCHEMES>) => React.JSX.Element;
    connection?: (connection: SCHEMES["Connection"]) => React.JSX.Element;
    socket?: (socket: S) => React.JSX.Element;
  },
  events?: {
    onInvalidConnection?: () => void
  }
}

GraphEditorContext 将作为 createEditor 的返回参数,对外提供 Rete 的组件以及销毁方法:

export interface GraphEditorContext<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>,
  SCHEMES extends BaseGraphSchemes<S, N, C>
> {
  // Rete 组件
  rete: {
    editor: NodeEditor<SCHEMES>;
    area:  AreaPlugin<SCHEMES, ReactArea2D<SCHEMES>>;
    connection: ConnectionPlugin<SCHEMES, ReactArea2D<SCHEMES>>;
  };
  // 自适应缩放
  autoFitViewport(): void;
  // 销毁方法
  destroy(): void;
}

接下来就是 createBaseGraphEditor 方法:

async function createBaseGraphEditor<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>,
  SCHEMES extends BaseGraphSchemes<S, N, C>
>(
  container: HTMLElement,
  props: CreateGraphEditorProps<S, N, C, SCHEMES>
): Promise<GraphEditorContext<S, N, C, SCHEMES>> {
  type AreaExtra = ReactArea2D<SCHEMES>;

  const editor = new NodeEditor<SCHEMES>();
  const area = new AreaPlugin<SCHEMES, AreaExtra>(container);
  const connection = new ConnectionPlugin<SCHEMES, AreaExtra>();
  const render = new ReactPlugin<SCHEMES, AreaExtra>({ createRoot });

  AreaExtensions.selectableNodes(area, AreaExtensions.selector(), {
    accumulating: AreaExtensions.accumulateOnCtrl(),
  });

  // Configure render
  render.addPreset(
    Presets.classic.setup({
      customize: {
        node(data) {
          return ({ emit }) => {
            return props.render?.node?.(data.payload, emit) ?? <Presets.classic.Node data={data.payload} emit={emit} />
          }
        },
        connection(data) {
          // const { source, target } = getConnectionSockets(editor, data.payload);
          return () => {
            return props.render?.connection?.(data.payload) ?? <Presets.classic.Connection data={data.payload} />
          };
        },
        socket(data) {
          return () => {
            return props.render?.socket?.(data.payload as S) ?? <Presets.classic.Socket data={data.payload} />
          }
        }
      },
    })
  );

  // Configure connection
  connection.addPreset(() => {
    return new ClassicFlow({
      canMakeConnection(from: SocketData, to: SocketData) {
        const [source, target] = getSourceTarget(from, to) || [null, null];

        if (!source || !target || from === to) return false;

        const sourceNode = editor.getNode(source.nodeId)!;
        const targetNode = editor.getNode(target.nodeId)!;

        const sockets = getConnectionSockets(
          editor,
          props.connectionFactory(
            sourceNode,
            source.key,
            targetNode,
            target.key
          )
        );

        if (!sockets.source!.isCompatibleWith(sockets.target!)) {
          props.events?.onInvalidConnection?.();
          connection.drop();
          return false;
        }

        const connected = editor
          .getConnections()
          .find((conn) => conn.source == sourceNode.id &&
            conn.target == targetNode.id &&
            conn.sourceOutput == source.key &&
            conn.targetInput == target.key
          ) != null;

        // Already connected before
        if (connected) {
          connection.drop();
          return false;
        }

        return Boolean(source && target);
      },
      makeConnection(from: SocketData, to: SocketData, context) {
        const [source, target] = getSourceTarget(from, to) || [null, null];
        const { editor } = context;

        if (source && target) {
          void editor.addConnection(
            props.connectionFactory(
              editor.getNode(source.nodeId)!,
              source.key,
              editor.getNode(target.nodeId)!,
              target.key
            )
          );
          return true;
        }
      }
    });
  });

  editor.use(area);
  area.use(connection);
  area.use(render);

  AreaExtensions.simpleNodesOrder(area);

  return {
    rete: {
      editor: editor,
      area: area,
      connection: connection,
    },
    autoFitViewport: () => {
      AreaExtensions.zoomAt(area, editor.getNodes())
    },
    destroy: () => area.destroy(),
  };
}

为了适配 useRete 的参数类型:

export declare function useRete<T extends {
    destroy(): void;
}>(create: (el: HTMLElement) => Promise<T>): readonly [React.RefObject<HTMLDivElement>, T | null];

因此需要再把上面的 createBaseGraphEditor 再包装一层:

export function useCreateReteBaseGraphEditor<
  S extends BaseGraphSocket,
  N extends BaseGraphNode<S>,
  C extends BaseGraphNodeConnection<S, N, N>,
  SCHEMES extends BaseGraphSchemes<S, N, C>
>(props: CreateGraphEditorProps<S, N, C, SCHEMES>) {
  return useCallback(
    (container: HTMLElement) => {
      return createBaseGraphEditor(container, props);
    },
    []
  )
}

注意这里必须用 useCallback 来接管这个返回结果,否则会导致 Rete 无限重复渲染。

测试封装

到这里所有的封装就结束了,现在可以用刚才封装好的类和方法,重新实现一个快速开始中的例子了:

定义插槽

type MySocketTypes = ExecSocket | ParamSocket;

class ExecSocket extends BaseGraphSocket {
  constructor() {
    super("Exec");
  }

  isCompatibleWith(socket: ClassicPreset.Socket): boolean {
    return socket instanceof ExecSocket;
  }
}

class ParamSocket extends BaseGraphSocket {
  constructor() {
    super("Param");
  }

  isCompatibleWith(socket: ClassicPreset.Socket): boolean {
    return socket instanceof ParamSocket;
  }
}

// 注意下面的插槽必须是单例
const execSocket = new ExecSocket();
const paramSocket = new ParamSocket();

定义节点与 Schemes

class MyBaseGraphNode extends BaseGraphNode<MySocketTypes> {
  constructor(nodeType: string) {
    super(nodeType);
  }
}

class MyBaseGraphConnection extends BaseGraphNodeConnection<MySocketTypes, MyBaseGraphNode, MyBaseGraphNode> {}

type MyGraphSchemes = BaseGraphSchemes<MySocketTypes, MyBaseGraphNode, MyBaseGraphConnection>

组件示例

function App() {
  const [ref, ctx] = useRete(
    useCreateReteBaseGraphEditor<
      MySocketTypes,
      MyBaseGraphNode,
      MyBaseGraphConnection,
      MyGraphSchemes
    >({
      connectionFactory: (
        fromNode
        , fromSocket,
        toNode,
        toSocket
      ) => {
        return new MyBaseGraphConnection(fromNode, fromSocket, toNode, toSocket)
      }
    })
  );

  useEffect(() => {
    if (!ctx) return

    const node1 = new MyBaseGraphNode("NODE1")
    node1.addOutputSocket("Exec", execSocket)
    node1.addOutputSocket("x", paramSocket)
    void ctx.rete.editor.addNode(node1)

    const node2 = new MyBaseGraphNode("NODE2")
    node2.addInputSocket("Exec", execSocket)
    node2.addInputSocket("y", paramSocket)
    void ctx.rete.editor.addNode(node2)

    void ctx.rete.editor.addConnection(new MyBaseGraphConnection(node1, "Exec", node2, "Exec"))
    void ctx.rete.editor.addConnection(new MyBaseGraphConnection(node1, "x", node2, "y"))

  }, [ctx]);

  return (
    <>
      <div>
        <div ref={ref} style={{ height: "100vh", width: "100vw" }}></div>
      </div>
    </>
  )
}

如果不出意外的话,你会看到 NODE1 和 NODE2 连在一起,并且不同类型的插槽不能连接。

结束

到这里 Rete.js 的基本使用就结束了,更多玩法可以在官方文档和示例中找到:https://retejs.org/examples

未经允许禁止转载本站内容,经允许转载后请严格遵守CC-BY-NC-ND知识共享协议4.0,代码部分则采用GPL v3.0协议
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇