自定义前端小部件开发 (Custom Widgets)
算子节点可以通过自定义 JavaScript 小部件 (Widget) 组件,超越标准内建参数控件的束缚,为用户提供极致丰富、专业沉浸的富交互 UI。小部件通常以独立的 .js 脚本文件形式打包,动态渲染进画布节点卡片容器内部,并通过统一的事件回调将数据变更实时同步回 Griptape Nodes 底层引擎。
小部件完整架构体系 (Widget Architecture)
一个完整的自定义小部件由三部分精密联动组成:
- 前端 JavaScript 脚本 (
widgets/MyWidget.js) — 负责 DOM 渲染、局部交互状态管理与事件监听; - 后端 Python 算子类 — 在对应参数上通过
Widget特征特征 (Trait) 进行双向绑定; - 算子库清单 (
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 端Widgettrait 的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) 等测试开关。
标准本地沙盒联调步骤
- 进入沙盒工程目录并安装轻量依赖:
cd widget-testbed npm install - 在
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} />; } - 启动开发服务器并在浏览器中实时交互演练:
npm run dev - 验证完全无误后,再将该
.js挂载至 Griptape Nodes 画布中进行端到端闭环验证。