ReactFlow CommandNode 画布操作菜单

294 阅读10分钟

CommandNode 实现深度解析:打造交互式画布操作菜单

在现代 Web 应用中,尤其是在可视化和流程编辑场景下,提供流畅、直观的用户交互至关重要。React Flow 是一个强大的库,用于构建基于节点的图表和编辑器。本文将深入探讨在基于 React Flow 的画布中,一个名为 commandNode 的特殊节点的实现。这个 commandNode 主要用于在双击画布空白区域或尝试进行非法节点连接时,弹出一个上下文感知的操作菜单,引导用户进行下一步操作。

我们将围绕以下几个核心问题展开:

  1. commandNode 是如何实现的?
  2. 如何向 commandNode 传递必要的信息(如位置、来源节点上下文)?
  3. 如何触发 commandNode 的打开和关闭?
  4. commandNode 的出现和消失动画是如何实现的?
  5. 在连线结束(onConnectEnd)时,如何利用 commandNode 辅助用户操作?
  6. 当从不同类型的节点拖出非法连线时,commandNode 如何展示不同的菜单选项?

1. commandNode 的实现机制

commandNode 本质上是一个自定义的 React Flow 节点。

a. 节点注册与定义: 首先,在项目的节点类型定义中(通常在 packages/canvas/src/core/nodes/index.ts),CommandNode 组件会被引入并注册到 nodeTypes 对象中:

// packages/canvas/src/core/nodes/index.ts
import CommandNode, { CommandNodeType } from "./command/command-node";
// ...其他节点导入

export enum NodeType {
  // ...其他节点类型
  COMMAND = "command",
}

export const nodeTypes = {
  // ...其他节点
  [NodeType.COMMAND]: CommandNode,
} as unknown as NodeTypes;

export type AppNode =
  // ...其他节点类型
  | CommandNodeType;

b. 组件结构 (command-node.tsx): CommandNode 组件 (packages/canvas/src/core/nodes/command/command-node.tsx) 负责渲染菜单界面。它通常使用 UI 组件库(如本项目中的 @tap/shadcnCommand 系列组件)来构建。

// packages/canvas/src/core/nodes/command/command-node.tsx
import { Command, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@tap/shadcn";
import { Handle, Position, useReactFlow, type Node } from "@xyflow/react";
import { useGlobalState } from "../../context/global-state";
// ... 其他导入

const CommandNode = (commandNodeProps: CommandNodeType) => {
  const { connectionState, commandPosition, setConnectionState } = useGlobalState();
  const { getNode, setNodes, getEdges, setEdges, addNodes } = useReactFlow(); // addNodes 也可能用到

  // ... (逻辑见后续部分)

  return (
    <Command className="border w-64 nowheel nodrag nopan animate-in zoom-in origin-top-left">
      <Handle type="target" position={handlePosition} className="invisible" />
      <Handle type="source" position={handlePosition} className="invisible" />
      <CommandList>
        <CommandEmpty>No commands found.</CommandEmpty>
        {/* 渲染命令组和命令项 */}
      </CommandList>
    </Command>
  );
};

export default CommandNode;

export type CommandNodeType = Node<{ type: "command" }, "command">;

该组件还包含不可见的 Handle 元素。虽然 commandNode 主要作为菜单,这些 Handleposition 会动态计算。在 onConnectEnd 场景下,会有一条临时边连接到 commandNode,当用户从菜单中选择一个操作(比如创建新节点)后,这条临时边会被替换成连接到新节点的边,此时 Handle 的定位就可能参与到新边的创建逻辑中。

c. 节点工厂函数 (createCommandNode): 为了方便创建 commandNode 实例,通常会有一个工厂函数:

// packages/canvas/src/core/lib/node-factory/command.ts
import { XYPosition } from "@xyflow/react";
import { v4 as uuidv4 } from "uuid";
import { AppNode, NodeType } from "../../nodes";
import { CommandNodeType } from "../../nodes/command/command-node";

export const createCommandNode = (data: CommandNodeType["data"], position: XYPosition): AppNode => {
  const commandNode: AppNode = {
    id: `${NodeType.COMMAND}-${uuidv4()}`, // 唯一ID
    type: NodeType.COMMAND,                // 类型
    data,                                  // 节点特定数据 (当前实现中data为空)
    position,                              // 在画布上的位置
    draggable: false,                       // 不可拖动
    zIndex: 1000,                           // 确保在顶层显示
  };
  return commandNode;
};

此函数创建的节点具有 draggable: false 和高 zIndex 的特性,符合菜单的行为。

2. 如何向 commandNode 传递信息?

commandNode 的行为和显示内容依赖于上下文信息,这主要通过一个全局状态来管理。

a. 全局状态 (global-state.tsx): 项目使用 React Context API (packages/canvas/src/core/context/global-state.tsx) 创建了一个全局状态,包含:

  • connectionState: FinalConnectionState | null: 存储当前正在进行的连接操作的详细信息,如起始节点 (fromNode)、起始 Handle (fromHandle)、是否有效等。
  • commandPosition: XYPosition | null: 存储 commandNode 应该显示的位置。
  • 相应的 setter 函数 setConnectionStatesetCommandPosition
export const GlobalStateContext = createContext<{
  connectionState: FinalConnectionState | null;
  setConnectionState: (connectionState: FinalConnectionState | null) => void;
  commandPosition: XYPosition | null;
  setCommandPosition: (position: XYPosition) => void;
}>({
  connectionState: null,
  setConnectionState: () => {},
  commandPosition: null,
  setCommandPosition: () => {},
});

export const GlobalStateProvider = ({ children }: { children: ReactNode }) => {
  const [connectionState, setConnectionState] = useState<FinalConnectionState | null>(null);

  const [commandPosition, setCommandPosition] = useState<XYPosition | null>(null);

  return (
    <GlobalStateContext.Provider
      value={{
        connectionState,
        setConnectionState,
        commandPosition,
        setCommandPosition,
      }}
    >
      {children}
    </GlobalStateContext.Provider>
  );
};

export const useGlobalState = () => {
  const context = useContext(GlobalStateContext);
  if (!context) {
    throw new Error("useGlobalState must be used within a GlobalStateProvider");
  }
  return context;
};

b. CommandNode 内部使用全局状态: CommandNode 组件通过 useGlobalState() hook 来访问这些状态:

// packages/canvas/src/core/nodes/command/command-node.tsx
const { connectionState, commandPosition, setConnectionState } = useGlobalState();

// 基于 connectionState.fromNode?.type 和 connectionState.fromHandle?.type
// 来决定显示哪些特定的命令 (见第6点)
const fromNodeType = useMemo(() => connectionState?.fromNode?.type, [connectionState]);
// ...

commandNode 需要显示时,它的位置由 commandPosition 决定。如果 connectionState 非空(通常在非法连接时),commandNode 会利用其中的信息(如来源节点类型)来动态调整显示的菜单选项。

3. 如何触发 commandNode 的打开和关闭?

打开 commandNode: 打开 commandNode 的核心操作是将一个 NodeType.COMMAND 类型的节点实例添加到 React Flow 的 nodes 数组中,并同时设置全局状态中的 commandPosition (以及可能的 connectionState)。

  • 非法连接时 (useConnectEnd.ts): 当用户尝试连接两个节点但连接无效时(例如,类型不匹配),onConnectEnd 回调被触发。
    // packages/canvas/src/core/hooks/useConnectEnd.ts
    // ...
    const onConnectEnd = useCallback(
      (event: MouseEvent | TouchEvent, connState: FinalConnectionState) => {
        if (!connState.isValid) { // 关键:连接无效
          // ... (检查 fromNodeType 是否允许弹出菜单)
          const position = screenToFlowPosition({ x: clientX, y: clientY });
          const cmdNode = createCommandNode({ type: "command" }, position);
    
          requestAnimationFrame(() => {
            addNodes([cmdNode]); // 添加 commandNode 到画布
            // ... (添加一条从源节点到 commandNode 的临时边)
            setCommandPosition(position);      // 设置全局位置
            setConnectionState(connState);   // 设置全局连接状态
          });
        }
      },
      // ...
    );
    
  • 双击画布空白区域时 (usePaneClick.ts): 当用户双击画布的空白区域,onPaneClick (内部调用 onDoubleClick) 会被触发。
    // packages/canvas/src/core/hooks/usePaneClick.ts
    // ...
    const onDoubleClick = useCallback(
      (event: MouseEvent) => {
        // ... (双击检测逻辑)
        const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
        setCommandPosition(position); // 设置全局位置
        // setConnectionState(null) 会被 onPaneClick 调用,或默认为 null
    
        const cmdNode = createCommandNode({ type: "command" }, position);
        addNodes([cmdNode]); // 添加 commandNode 到画布
        // ...
      },
      // ...
    );
    
    const onPaneClick = useCallback((event: MouseEvent) => {
        if (target.classList.contains("react-flow__pane")) {
            setNodes((nds) => nds.filter((node) => node.type !== NodeType.COMMAND)); // 先关闭已有的
            setConnectionState(null); // 清除连接状态
            onDoubleClick(event);
        }
    }, /* ... */);
    
    在这种情况下,connectionState 会是 null,因此 CommandNode 会显示默认的命令列表(例如,创建新节点的选项)。

关闭 commandNode: 关闭 commandNode 的核心操作是从 React Flow 的 nodes 数组中移除 NodeType.COMMAND 类型的节点,并清除相关的全局状态。

  • 选择命令后 (command-node.tsx): 当用户在 commandNode 中点击一个命令项时,该命令项的 onClick 回调执行后,通常会创建新节点、建立连接等。完成后,会主动移除 commandNode 并清理状态:
    // packages/canvas/src/core/nodes/command/command-node.tsx
    const handleClick = async (onClickCallback: (fromNode?: AppNode) => unknown) => {
      // ... (执行 onClickCallback, 创建新节点 newNode)
      if (newNode) {
        // ... (添加 newNode, 设置边)
        setNodes((nds) => [...nds.filter((node) => node.type !== NodeType.COMMAND), _newNode]);
      } else {
        // 如果没有新节点创建(例如只是一个操作),也移除commandNode
        setNodes((nds) => nds.filter((node) => node.type !== NodeType.COMMAND));
      }
      setConnectionState(null); // 清除连接状态
      // setCommandPosition(null) 可以选择性添加,如果希望位置状态也重置
    };
    
  • 点击画布空白区域 (usePaneClick.ts): 如上所述,在 usePaneClick.ts 中,如果用户在 commandNode 打开的状态下点击了画布的空白区域,会首先移除当前的 commandNode,然后再根据是否双击来决定是否打开新的 commandNode
    // packages/canvas/src/core/hooks/usePaneClick.ts
    setNodes((nds) => nds.filter((node) => node.type !== NodeType.COMMAND));
    setConnectionState(null);
    

4. commandNode 的动画过渡

commandNode 的出现动画是通过 CSS 类名在组件渲染时应用的。 在 packages/canvas/src/core/nodes/command/command-node.tsx 中:

<Command className="border w-64 nowheel nodrag nopan animate-in zoom-in origin-top-left">
  {/* ... */}
</Command>

这里的 animate-in zoom-in origin-top-left 是关键。这些类名通常来自像 Tailwind CSS这样的原子化 CSS 框架或自定义的 CSS 动画配置。

  • animate-in: 触发进入动画。
  • zoom-in: 指定了缩放进入的动画效果。
  • origin-top-left: 定义了缩放动画的原点。

commandNode 被添加到 nodes 数组并渲染到 DOM 时,这些 CSS 类会使其平滑地从左上角缩放出现。消失动画则依赖于React的卸载机制和可能的CSS退出动画(如果配置了的话)。

5. 如何利用 onConnectEnd 打开 commandNode

这部分已在第3点的“打开 commandNode - 非法连接时”详细解释。总结来说:

  1. React Flow 的 onConnectEnd prop 绑定到 useConnectEnd hook 返回的 onConnectEnd 函数。
  2. 该函数在连接操作结束时被调用,并接收连接状态 connectionState
  3. 它检查 !connectionState.isValid 来判断连接是否非法。
  4. 检查 connectionState.fromNode.type 是否属于预定义的允许触发菜单的节点类型 (如 text, image, videohaveMenuNodeTypes 数组中)。
  5. 如果条件满足,它会:
    • 计算鼠标释放点在画布中的位置。
    • 使用 createCommandNode 创建一个 commandNode 实例。
    • 通过 addNodes 将此 commandNode 添加到画布。
    • 通过 addEdges 添加一条从源节点到此 commandNode 的临时、不可删除的边,以在视觉上指示操作流程。
    • 通过 setCommandPositionsetConnectionState 更新全局状态,将位置和连接上下文传递给即将渲染的 CommandNode

CommandNode 组件随后利用这些全局状态来渲染自身,并根据 connectionState 提供上下文相关的操作选项。

6. 如何处理不同节点拉取非法连线时展开的菜单选项?

这是 commandNode 设计中的一个核心亮点,它提供了上下文感知的能力。

a. 动态命令获取 (command-node.tsx): CommandNode 内部有一个 getCommands 函数和相应的 useMemo Hook 来决定显示哪些命令。

// packages/canvas/src/core/nodes/command/command-node.tsx
// ... (导入 useTextNodeCommands, useImageNodeCommands, useVideoNodeCommands 等)

const defaultCommands: CommandConfig[] = [ /* ...默认命令... */ ];

const CommandNode = (commandNodeProps: CommandNodeType) => {
  const { connectionState } = useGlobalState();
  const fromNodeType = useMemo(() => connectionState?.fromNode?.type, [connectionState]);
  const fromNodeHandleType = useMemo(() => connectionState?.fromHandle?.type, [connectionState]);

  // 为不同节点类型及其 Handle (source/target) 定义的命令
  const { textNodeLeftCommands, textNodeRightCommands } = useTextNodeCommands();
  const { imageNodeLeftCommands, imageNodeRightCommands } = useImageNodeCommands();
  const { videoNodeLeftCommands /*, videoNodeRightCommands (if any) */ } = useVideoNodeCommands();

  const getCommands = useCallback(
    (type: string, handleType: string): CommandConfig[] | null => {
      if (handleType === "source") { // 从节点的 source handle 拖出
        switch (type) {
          case "text": return textNodeRightCommands;
          case "image": return imageNodeRightCommands;
          // case "video": return videoNodeRightCommands;
          default: return []; // 或 null,取决于希望如何处理未配置的情况
        }
      }
      if (handleType === "target") { // 从节点的 target handle 拖出
        switch (type) {
          case "text": return textNodeLeftCommands;
          case "image": return imageNodeLeftCommands;
          case "video": return videoNodeLeftCommands;
          default: return [];
        }
      }
      return null; // 无效的 handleType
    },
    [/* ...依赖的命令列表... */]
  );

  const commands: CommandConfig[] = useMemo(() => {
    if (!fromNodeType || !fromNodeHandleType) return defaultCommands; // 无上下文,用默认
    const specificCommands = getCommands(fromNodeType, fromNodeHandleType);
    return specificCommands && specificCommands.length > 0 ? specificCommands : defaultCommands; // 如果没找到特定命令,也用默认 (或特定提示)
  }, [fromNodeType, fromNodeHandleType, getCommands]);

  // ... (渲染 commands)
};

b. 特定节点的命令定义 (例如 useTextNodeCommands): 每个节点类型可以有自己的 hook (如 useTextNodeCommandsuseImageNodeCommands) 来定义当从该节点的特定 Handle (source 或 target) 拖出非法连接时应显示的命令。

// 示例: packages/canvas/src/core/nodes/text/commands.ts (假设的路径和结构)
// import { CommandConfig } from "../command/command-node";
// import { createSomeNodeForText } from "../../lib/node-factory";

// export const useTextNodeCommands = () => {
//   const textNodeRightCommands: CommandConfig[] = [
//     {
//       title: "从文本节点 (右侧) 出发",
//       children: [
//         {
//           label: "连接到图片",
//           icon: /* Icon */,
//           onClick: (fromNode) => { /* create image node and connect */ }
//         },
//         // ... 其他命令
//       ]
//     }
//   ];
//   const textNodeLeftCommands: CommandConfig[] = [ /* ... */ ];
//   return { textNodeLeftCommands, textNodeRightCommands };
// }

这些特定节点的命令 (CommandConfig[]) 结构与 defaultCommands 相同,包含标题、子命令列表(标签、图标、点击回调)。onClick 回调在 CommandNodehandleClick 中执行时,会接收到来源节点 fromNode 作为参数,允许创建与来源节点相关的连接或操作。

c. 汇总:onConnectEnd 触发 commandNode 时,它会把 connectionState (包含 fromNodefromHandle 信息) 设置到全局状态。CommandNode 组件读取这些信息,通过 getCommands 查找到对应来源节点类型和 Handle 类型的特定命令列表。如果找不到特定命令,则回退到 defaultCommands。这样就实现了高度上下文感知的菜单系统。

总结

通过组合自定义 React Flow 节点、全局状态管理 (React Context)、精巧的事件处理 ( onConnectEnd, onPaneClick) 以及动态命令加载机制,commandNode 实现了一个强大且用户友好的交互式菜单系统。它不仅能在用户进行非法操作时提供有效引导,还能在画布空白处快速创建新节点,极大地提升了画布的可用性和操作效率。这种模式清晰地分离了关注点,使得 commandNode 自身、状态管理、触发逻辑以及特定节点的命令定义都易于理解和扩展。