跳转至

自定义前端小部件开发 (Custom Widgets)

算子节点可以通过自定义 JavaScript 小部件 (Widget) 组件,超越标准内建参数控件的束缚,为用户提供极致丰富、专业沉浸的富交互 UI。小部件通常以独立的 .js 脚本文件形式打包,动态渲染进画布节点卡片容器内部,并通过统一的事件回调将数据变更实时同步回 Griptape Nodes 底层引擎。


小部件完整架构体系 (Widget Architecture)

一个完整的自定义小部件由三部分精密联动组成:

  1. 前端 JavaScript 脚本 (widgets/MyWidget.js) — 负责 DOM 渲染、局部交互状态管理与事件监听;
  2. 后端 Python 算子类 — 在对应参数上通过 Widget 特征特征 (Trait) 进行双向绑定;
  3. 算子库清单 (griptape_nodes_library.json) — 向系统注册并广播该小部件的静态定位路径。
library_name/
├── griptape_nodes_library.json
├── my_node.py
└── widgets/
    └── MyWidget.js

1. 在算子库清单中登记小部件 (Registering)

在 griptape_nodes_library.json 的顶层添加 "widgets" 声明数组:

{
  "name": "My Library",
  "widgets": [
    {
      "name": "MyWidget",
      "path": "widgets/MyWidget.js",
      "description": "专为高级序列编辑打造的自定义控件"
    }
  ],
  "nodes": [ ... ]
}
  • name:小部件在系统内部的唯一标识名称,必须与 Python 端 Widget trait 的 name 严格一致;
  • path:相对算子库清单根目录的 .js 路径。

2. 在 Python 参数上挂载小部件 (Attaching to Parameter)

使用 Widget Trait 将指定参数与该前端小部件绑定。参数的当前值会被作为 props.value 灌入 JavaScript 控件,而前端的数据变更则通过 props.onChange 管道回流:

from griptape_nodes.exe_types.core_types import Parameter, ParameterMode
from griptape_nodes.traits.widget import Widget

self.add_parameter(
    Parameter(
        name="my_data",
        input_types=["list"],
        type="list",
        output_type="list",
        default_value=[],
        tooltip="由自定义前端小部件深度管理的结构化数据列表",
        allowed_modes={ParameterMode.PROPERTY, ParameterMode.OUTPUT},
        traits={Widget(name="MyWidget", library="My Library")},
    )
)

3. JavaScript 小部件标准函数签名 (Widget JS Signature)

小部件采用 ES 模块化语法默认导出(export default)。该函数接收一个父级挂载容器 DOM 元素与一个 props 属性字典,且必须返回一个资源清理清理析构函数 (cleanup):

export default function MyWidget(container, props) {
  const { value, onChange, disabled, height } = props;

  // 1. 在 container 内部构建或刷新富交互 DOM
  // 2. 当用户操作产生新数据时,调用 onChange(newValue) 回调
  // 3. 遵从 disabled 状态,适时禁用控件的可编辑性

  // 4. 返回资源清理闭包函数
  return () => {
    // 移除全局 document 监听器,销毁定时器或释放第三方库实例
  };
}

Props 入参规范

属性名称 数据类型 详细说明
value any 参数的当前最新值(首次加载时与 Python 端的 default_value 保持一致)
onChange function 状态上报管道:onChange(newValue),通知引擎持久化最新数值并广播下游
disabled boolean 是否处于只读锁定禁用状态
height number 建议的视口预分配物理高度(像素值,可能为 0 或缺省)

核心设计范式与工程踩坑警示 (Critical Pitfalls)

⚠️ 核心法则 1:克制触发 onChange——绝对禁止在每一个按键输入时调用!

这是编写自定义小部件最容易踩入的“致命大坑”: 调用 onChange 会触发 Griptape Nodes 底层 React 状态机进行深度 Diff 与全量重新渲染,这会导致当前处于焦点的 DOM 元素瞬间丧失焦点 (Blur)!若在多行文本框的 input 事件中每敲一个字符就触发一次 onChange,用户每输入一个英文字母文本框就会失焦一次,导致用户完全无法连续打字。

官方标准组件(如 TextComponent)所遵循的工业级范式:

  • 局部数据缓存 (Local state):用户在文本框敲击键盘时,仅就地更新小部件内部持有的局部数组、字符计数器或边框颜色(响应 input 事件),绝不调用 onChange;
  • 失焦上报 (blur):唯有当用户鼠标点击画布空白处或按 Tab 离开当前输入框时,在 blur 事件中统一触发 onChange 上报框架;
  • 离散交互控件即时上报:对于无需维持光标焦点的离散操作(如点击增减按钮、切换步进计数器、拖拽松手释放),由于不抢占键盘焦点,可立即调用 onChange。
// ✅ 正确做法:打字时仅更新局部状态与字符统计
textarea.addEventListener("input", (e) => {
  localData[index].text = e.target.value;
  updateCharacterCount(e.target.value.length);
});

// ✅ 离开焦点时才将最终数据批量同步回引擎
textarea.addEventListener("blur", () => {
  localData[index].text = textarea.value;
  onChange(structuredClone(localData));
});

// ✅ 离散按钮操作可直接上报
button.addEventListener("pointerdown", (e) => {
  e.stopPropagation();
  localData[index].count++;
  onChange(structuredClone(localData));
  render();
});

为什么不能用 requestAnimationFrame 挽救焦点? 因为 React 的异步重排周期与浏览器的宏微任务交织,在重排后强行 focus() 会产生难以控制的竞态冲突与光标乱跳。


⚠️ 核心法则 2:彻底阻断画布拖拽事件劫持 (nodrag / nowheel)

Griptape Nodes 画布本身绑定了全屏手势监听用于平移、缩放与节点拖动。小部件内部的可交互控件必须阻断事件冒泡,并在容器外层标记保护样式类:

// 最外层容器必须追加 nodrag nowheel 类
const wrapper = document.createElement("div");
wrapper.className = "my-widget nodrag nowheel";

// 内部所有滑块、文本域、滚动框均需阻断指针穿透
textarea.addEventListener("pointerdown", (e) => e.stopPropagation());
textarea.addEventListener("mousedown", (e) => e.stopPropagation());

⚠️ 核心法则 3:拦截键盘快捷键穿透 (Keyboard Shortcut Isolation)

画布在全局监听了诸如 Delete(删除当前节点)、Ctrl+C、Ctrl+V 等快捷键。当用户正在小部件文本域内按 Delete 删除打错的文字时,若未阻断事件冒泡,会导致整个算子节点在画布中被瞬间彻底删除!

// 必须在 keydown 事件中彻底截断按键冒泡
textarea.addEventListener("keydown", (e) => e.stopPropagation());

⚠️ 核心法则 4:覆写文本框的 user-select

为了防止用户在画布拖拽节点时误触发文字选中高亮,上层节点外框普遍预设了 user-select: none;。此 CSS 属性会级联穿透进小部件,导致用户无法在输入框中通过鼠标拖蓝选中文本。必须在文本输入域上显式强行重置:

textarea, input[type="text"] {
  user-select: text !important;
  -webkit-user-select: text !important;
}

⚠️ 核心法则 5:上报数据前必须进行深拷贝 (Deep Clone)

向 onChange 传递数据时,绝对不能直接传递小部件内部数组对象的原始引用,必须使用 structuredClone 或解构拷贝传递副本,防止外部框架与内部引用发生内存交叉污染:

onChange(localData.map((item) => ({ ...item })));
// 或
onChange(structuredClone(localData));

⚠️ 核心法则 6:为可排序列表项赋予稳定唯一的 UUID (Stable IDs)

当小部件承载多镜头脚本卡片等“可拖拽重排”列表时,切勿以数组下标 index 作为项的唯一标识!当用户将第 3 项拖到第 1 项时,若依赖下标绑定,其内部子状态(输入框光标、未提交草稿)会错位混乱。必须在数据初始化与新增时注入稳定的全局 ID:

let nextId = 1;

function ensureStableId(item) {
  if (!item.id) {
    item.id = `item-${nextId++}`;
  }
  return item;
}

// 即使在重排与多轮 onChange 回流后,item.id 永远不变;卡片展示的标题(如 "镜头 1")仅作纯视觉渲染

独立小部件沙盒调试套件 (Widget Testbed)

为了免去每次微调前端 UI 都要启动完整 Python 引擎与桌面客户端的繁琐开发周期,官方提供了基于 React + Vite 的独立轻量化调试沙盒:widget-testbed。

沙盒的核心工程价值

  • 毫秒级极速热重载 (Hot Reload):直接在现代浏览器中编写并秒级预览小部件的 DOM 表现;
  • 状态监控与模拟注入:内置 JSON 实时状态面板,可直观监控 onChange 产出的数据结构;
  • 边缘极端场景演练:提供一键切换只读锁定 (Disabled)、一键重置 (Reset) 等测试开关。

标准本地沙盒联调步骤

  1. 进入沙盒工程目录并安装轻量依赖:
    cd widget-testbed
    npm install
    
  2. 在 src/App.jsx 中引入你正在编写的小部件,并使用 WidgetHost 宿主组件挂载:
    import WidgetHost from "./WidgetHost";
    import MyListEditor from "../../my-library/widgets/MyListEditor.js";
    
    export default function App() {
      const [value, setValue] = useState(INITIAL_STATE);
      return <WidgetHost widgetFn={MyListEditor} value={value} onChange={setValue} />;
    }
    
  3. 启动开发服务器并在浏览器中实时交互演练:
    npm run dev
    
  4. 验证完全无误后,再将该 .js 挂载至 Griptape Nodes 画布中进行端到端闭环验证。