コンテンツにスキップ

カスタムウィジェット (Custom Widgets)

ノードでは、標準のパラメータコントロールを超えたリッチでインタラクティブな UI を提供するために、カスタム JavaScript ウィジェットコンポーネントを使用できます。ウィジェットは、コンテナ要素内にレンダリングされ、コールバックを介して値の変更をフレームワークに送り返す独立した .js ファイルです。

ウィジェットのアーキテクチャ (Widget Architecture)

カスタムウィジェットは主に 3 つの要素で構成されます:

  1. ウィジェット JS ファイル (widgets/MyWidget.js) — UI コンポーネント本体
  2. ノード Python ファイル — パラメータ上の Widget トレイトを介してウィジェットを参照
  3. ライブラリ JSON (griptape_nodes_library.json) — フレームワークがウィジェットを検出できるように登録
library_name/
├── griptape_nodes_library.json
├── my_node.py
└── widgets/
    └── MyWidget.js

ウィジェットの登録 (Registering a Widget)

griptape_nodes_library.json に "widgets" 配列を追加します:

{
  "name": "My Library",
  "widgets": [
    {
      "name": "MyWidget",
      "path": "widgets/MyWidget.js",
      "description": "ウィジェットの説明"
    }
  ],
  "nodes": [ ... ]
}

name は Python 側の Widget トレイトで使用される名前と一致している必要があり、library 引数は JSON のトップレベルにある "name" フィールドと一致している必要があります。

パラメータへのウィジェットのアタッチ

パラメータをカスタムウィジェットにバインドするには、Widget トレイトを使用します。パラメータの値は props.value としてウィジェットに渡され、変更は 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")},
    )
)

ウィジェット JS の関数シグネチャ

ウィジェットは ES モジュールのデフォルトエクスポートとして記述します。この関数はコンテナとなる DOM 要素と props オブジェクトを受け取り、クリーンアップ関数を返す必要があります:

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

  // `container` 内に UI を構築
  // ユーザーがデータを変更したときに `onChange(newValue)` を呼び出す
  // 適切な場合は `disabled` を尊重して操作を防止する

  // クリーンアップ関数を返す
  return () => {
    // イベントリスナーの削除、リソースの破棄
  };
}

Props:

Prop 型 説明
value any 現在のパラメータ値(Python 側のデフォルト値と一致)
onChange function 更新された値をフレームワークに送信するコールバック
disabled boolean ウィジェットを読み取り専用にすべきかどうか
height number 推奨される高さ(ピクセル単位、0 または省略可能)

重要なパターンと落とし穴 (Critical Patterns and Pitfalls)

変更通知は控えめに — 1 文字ごとのキー入力で送信しない

onChange を呼び出すと、フレームワークの状態更新がトリガーされ、アクティブな要素からフォーカスが奪われます。テキスト入力の場合、キーストロークのたびに textarea がフォーカスを失い、文字の入力が不可能になります。これはカスタムウィジェット固有の問題ではなく、Griptape Nodes エディタの組み込み TextComponent でも同じパターンが採用されています:

  • ローカル状態(内部データ配列、カウンター、ボーダー色など)は、毎回の input イベントで即座に更新します。
  • onChange は blur 時(ユーザーがフィールドの外をクリックした、またはタブで移動したとき)にのみ呼び出します。
  • 離散的なコントロール(ボタン、ステッパー、ドラッグ終了時など)はフォーカスを保持しないため、即座に onChange を呼び出して問題ありません。
// ローカル状態はキーストロークごとに更新 — UI の応答性を維持
textarea.addEventListener("input", (e) => {
  localData[index].text = e.target.value;
  // ここで文字数カウンターやボーダー色などを更新
});

// ユーザーがフィールドを離れたときにのみフレームワークへ通知
textarea.addEventListener("blur", () => {
  localData[index].text = textarea.value;
  onChange(structuredClone(localData));
});

// 離散的コントロール(ボタン、ステッパー)は即座に通知可能
button.addEventListener("pointerdown", (e) => {
  e.stopPropagation();
  localData[index].value++;
  onChange(structuredClone(localData));
  render();
});

フォーカスを復元するために requestAnimationFrame を使わない理由: キーストロークごとに onChange を呼び出し、その後に requestAnimationFrame でフォーカスを復元しようとしても、確実には動作しません — フレームワークの React レンダリングサイクルは非同期に完了する可能性があり、フォーカスの復元処理と競合(レース)するためです。

ノードのドラッグ干渉を防止する

ノードキャンバスは、パンやノード移動のためのドラッグイベントを処理します。ウィジェット内のインタラクティブな要素は、イベントの伝播を停止し、nodrag / nowheel CSS クラスを使用する必要があります:

// 最も外側のラッパー要素に対して
const wrapper = document.createElement("div");
wrapper.className = "my-widget nodrag nowheel";

// インタラクティブな子要素(textarea、slider など)に対して
textarea.addEventListener("pointerdown", (e) => e.stopPropagation());
textarea.addEventListener("mousedown", (e) => e.stopPropagation());

キーボードショートカットの干渉を防止する

ノードエディタはキャンバスレベルでキーボードショートカットをバインドしています(例: Delete キーを押すと選択されたノードが削除されます)。ウィジェット内のテキスト入力にフォーカスがある場合でも、キーボードイベントがバブリングするため、これらのショートカットが発火してしまいます。テキスト入力を分離するために、keydown で伝播を停止してください:

textarea.addEventListener("keydown", (e) => e.stopPropagation());

これにより、ユーザーがテキスト編集中に Delete キーを押してもノードが削除されるのを防ぎ、他のキャンバスレベルのショートカット(ノードレベルのコピー、ペースト、元に戻すなど)が通常のテキスト編集を妨害するのを防ぎます。

テキスト入力用の user-select: none を上書きする

ウィジェットのラッパーは通常、ドラッグ操作中の偶発的なテキスト選択を防ぐために user-select: none を設定します。これは子要素にもカスケード(継承)され、textarea のテキスト編集がブロックされてしまいます。明示的に上書きしてください:

textarea {
  user-select: text;
  -webkit-user-select: text;
}

値を送信する前にクローンを作成する

onChange には、内部状態への参照ではなく、常に新しいコピーを渡してください。そうしないと、フレームワークとウィジェットが同じオブジェクトを共有することになり、検出困難な不具合の原因となります:

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

Document レベルのリスナーをクリーンアップする

ドラッグ&ドロップや外部クリックによる閉じる処理などのために document にリスナーをアタッチした場合は、クリーンアップ関数でそれらを削除してください:

document.addEventListener("pointerdown", onDocumentClick, true);

return () => {
  document.removeEventListener("pointerdown", onDocumentClick, true);
};

リスト項目に安定した ID を割り当てる

並べ替え可能なリストを管理するウィジェット(ドラッグ&ドロップによるショットエディタなど)を作成する場合は、各項目に配列のインデックスとは独立した一意の ID を割り当ててください。安定した ID がないと、ドラッグ&ドロップで並べ替える際にテキストフィールドの内容などの項目属性が失われる可能性があります(ウィジェットがゼロから再レンダリングされ、識別情報が配列の位置に紐づいているため)。

let nextItemId = 1;

function assignId(item) {
  if (!item.id) {
    item.id = `item-${nextItemId++}`;
  } else {
    const num = parseInt(item.id.replace("item-", ""), 10);
    if (!isNaN(num) && num >= nextItemId) {
      nextItemId = num + 1;
    }
  }
  return item;
}

// 初期化時 — 保存されたデータから既存の ID を保持
let items = value.map((v) => assignId({ ...v }));

// 新しいアイテムの追加時
items.push(assignId({ name: "New Item", text: "" }));

ID は並べ替え、再レンダリング、および onChange を介した往復処理の間も永続します。表示名(例: "Shot1", "Shot2")は視覚的な位置に基づいて番号を振り直すことができますが、id は安定したまま維持されます。

DOM ヘルパー内で disabled 属性を正しく処理する

属性オブジェクトから要素を作成する DOM ヘルパー関数を作成する場合は、disabled 属性の扱いに注意してください。setAttribute("disabled", false) を使用しても、要素の無効化状態は 解除されません — 属性が何らかの形で存在するだけで要素が無効化されます。代わりにプロパティを使用してください:

if (key === "disabled") {
  element.disabled = !!val;
}

リストの末尾にドロップインジケーターを表示する

ドラッグ&ドロップによる並べ替えを実装する場合、ドロップターゲットインジケーター(例: 青い境界線)は、リストの最後の項目を超えてドラッグした際にも表示される必要があります。一般的なアプローチ: ドラッグ位置がすべての項目の下にある場合、存在しない次の項目に border-top を表示する代わりに、最後の項目に border-bottom を表示します:

if (dragOverIndex === items.length && item.index === lastIndex) {
  item.el.style.borderBottom = "2px solid #4a9eff";
} else if (item.index === dragOverIndex) {
  item.el.style.borderTop = "2px solid #4a9eff";
}

集計制約(合計の最小値/最大値)を強制する

リスト項目が特定の範囲内に収まる必要がある数値(例: 合計再生時間 3〜15 秒)を持つ場合は、両方向で制約を適用します:

  • 上限 (Ceiling): 合計が最大値を超える場合は、増加ステッパーと「追加」ボタンを無効化します。
  • 下限 (Floor): いずれかの項目を減らすと合計が最小値を下回る場合は、減少ステッパーを無効化します。
  • 削除時の自動補正: 項目を削除すると合計が最小値を下回る場合、差分を埋めるために最後に残った項目の値を増やします。
const MIN_TOTAL = 3;
const MAX_TOTAL = 15;

// 合計が最小値を下回る場合は減少を無効化
const wouldGoBelow = totalValue() - 1 < MIN_TOTAL;
const canDecrease = !disabled && item.value > MIN_VALUE && !wouldGoBelow;

// 削除時 — 最小合計を維持するために自動補正
trash.addEventListener("pointerdown", (e) => {
  e.stopPropagation();
  if (items.length <= 1) return;
  items.splice(index, 1);
  const total = totalValue();
  if (total < MIN_TOTAL) {
    const lastItem = items[items.length - 1];
    lastItem.value += MIN_TOTAL - total;
  }
  emitChange();
  render();
});

有効な範囲をユーザーが理解できるように、ステータスバーに両方の制限を表示します: "8s (3–15s)"。範囲外になった場合は赤色で強調表示します。

実装例: リストベースのエディタウィジェット

一般的なパターンは、追加、削除、並べ替え、インライン編集が可能な構造化アイテムのリストを管理するウィジェットです。主な実装の詳細:

  • 安定した ID: 並べ替えや onChange 経由の往復処理後も存続する一意の id を各項目に割り当てます。
  • ドラッグ&ドロップによる並べ替え: ドラッグハンドルに pointerdown をアタッチし、視覚的なフィードバック用のフローティングクローンを作成し、pointermove で挿入位置を追跡し、pointerup で並べ替えを確定します。ドロップ後にのみ onChange を呼び出します。リストの中間位置と末尾位置の両方にドロップインジケーターを表示します。
  • ステッパーコントロール: 制約された数値(例: 再生時間 1〜15 秒)には、ドロップダウンメニューの代わりに ▲/▼ ステッパーボタンを使用します。制約に違反する場合(項目ごとの最小/最大、全項目の合計の最小/最大)はボタンを無効化します。
  • 集計制約: すべての項目にわたる合計の最小値と最大値の両方を強制します。下限を維持するために削除時に自動補正します(集計制約を強制する を参照)。
  • バリデーション制約: 入力を暗黙的に無視するのではなく、追加ボタンやステッパーの矢印を無効化することで制限(最大項目数、最大合計値、最大テキスト長)を強制します。
  • ステータスフィードバック: 現在のカウントと制限値を示す小さなステータスバーを表示し(例: "3 / 6 shots", "8s (3–15s)")、有効な範囲やコントロールが無効化されている理由をユーザーに伝えます。
  • テキスト入力の分離: ノードのドラッグやキーボードショートカットの干渉を防ぐため、pointerdown、mousedown、keydown で伝播を停止します。onChange は blur 時にのみ送信します。
# Python 側 — ウィジェット付きのリストパラメータ
self.add_parameter(
    Parameter(
        name="items",
        input_types=["list"],
        type="list",
        output_type="list",
        default_value=[{"name": "Item1", "duration": 2, "description": ""}],
        allowed_modes={ParameterMode.PROPERTY, ParameterMode.OUTPUT},
        traits={Widget(name="MyListEditor", library="My Library")},
    )
)
// JS 側 — リストエディタウィジェットのスケルトン
export default function MyListEditor(container, props) {
  const { value, onChange, disabled } = props;

  // 安定した ID の割り当て
  let nextId = 1;
  function assignId(item) {
    if (!item.id) item.id = `item-${nextId++}`;
    return item;
  }

  let items = Array.isArray(value)
    ? value.map((v) => assignId({ ...v }))
    : [assignId({ name: "Item1", duration: 2, description: "" })];

  function emitChange() {
    if (!disabled && onChange) {
      onChange(items.map((item) => ({ ...item })));
    }
  }

  function render() {
    container.innerHTML = "";
    const wrapper = document.createElement("div");
    wrapper.className = "nodrag nowheel";

    items.forEach((item, index) => {
      // ... ドラッグハンドル、ステッパー、textarea、ゴミ箱アイコンを含むアイテム行を構築 ...

      // テキスト入力 — input 時にローカル更新、blur 時に送信
      textarea.addEventListener("input", (e) => {
        items[index].description = e.target.value;
      });
      textarea.addEventListener("blur", () => {
        items[index].description = textarea.value;
        emitChange();
      });

      // ノードレベルのイベントからテキスト入力を分離
      textarea.addEventListener("pointerdown", (e) => e.stopPropagation());
      textarea.addEventListener("mousedown", (e) => e.stopPropagation());
      textarea.addEventListener("keydown", (e) => e.stopPropagation());
    });

    container.appendChild(wrapper);
  }

  render();

  return () => { /* document レベルのリスナーをクリーンアップ */ };
}

ウィジェットテストベッド (Widget Testbed)

widget-testbed は、Griptape Nodes のフル環境の外部でカスタムウィジェットコンポーネントをテストおよび開発するためのスタンドアロン React + Vite アプリケーションです。ウィジェットの UI と動作を素早くイテレーションできる、軽量でホットリロード対応の開発環境を提供します。

目的 (Purpose)

Griptape Nodes のカスタムウィジェットは、独自の DOM と状態を管理する命令型の JavaScript 関数です。widget-testbed を利用することで以下のことが可能になります:

  • 迅速なプロトタイピング: 開発中に即座のホットリロードでウィジェットの動作をテスト
  • 分離されたテスト環境: 完全な Griptape Nodes アプリケーションを起動することなくウィジェットの UI/UX を開発
  • 状態管理の検証: 複雑な状態遷移やユーザー操作のテスト
  • ウィジェット間の切り替え開発: 異なるウィジェットのテストを容易に切り替え
  • UI 不具合のデバッグ: クリーンな環境でウィジェットのレンダリング結果と内部状態を検査

使用する場面 (When to Use)

以下のような場面で widget-testbed を使用します:

  • 新しいカスタムウィジェットコンポーネントを一から作成する場合
  • ウィジェットの動作上の問題(フォーカス喪失、ドラッグ&ドロップ、イベント処理など)をデバッグする場合
  • ウィジェットの状態管理と onChange コールバックパターンをテストする場合
  • ノードエディタの干渉を受けずにウィジェットの外観とレイアウトを確認する場合
  • 複雑な内部状態(リスト、エディタ、マルチステップフォームなど)を管理するウィジェットを開発する場合

ファイル構成 (File Structure)

widget-testbed/
├── index.html              # 最小限のスタイリングを持つエントリ HTML
├── package.json            # 依存関係 (React 19, Vite 6)
├── vite.config.js          # React プラグインを含む Vite 設定
├── src/
│   ├── main.jsx            # React アプリケーションのエントリポイント
│   ├── App.jsx             # コントロール付きのメインテストハーネス
│   └── WidgetHost.jsx      # 命令型ウィジェット用の React ラッパー
└── node_modules/           # 依存パッケージ

主要コンポーネント (Key Components)

WidgetHost.jsx

WidgetHost コンポーネントは、Griptape Nodes ウィジェットと同じ (container, props) シグネチャを使用して命令型ウィジェット関数をホストする React ラッパーです。不要な再レンダリングを防ぎながら、ウィジェットのマウント、更新、アンマウントのライフサイクルを処理します。

主な特徴:

  • 命令型ウィジェットのサポート: コンテナ要素と props を使用してウィジェット関数を呼び出します
  • スマートな再マウント: リセットボタンなどの外部からの値変更時にのみウィジェットを再マウントします
  • onChange の識別: 変更がウィジェット内部から発生したものか、親コンポーネントから発生したものかを追跡します
  • クリーンアップ管理: アンマウント時または再マウント時にウィジェットのクリーンアップ関数を適切に呼び出します

Props:

Prop 型 説明
widgetFn function レンダリングするウィジェット関数 (container, props) => cleanup
value any 現在のウィジェットの値
onChange function ウィジェットが変更を送信したときのコールバック
disabled boolean ウィジェットを読み取り専用にすべきか(デフォルト: false)
height number 推奨される高さ(ピクセル単位、デフォルト: 0)

実装パターン:

import WidgetHost from "./WidgetHost";
import MyWidget from "../../path/to/widgets/MyWidget.js";

export default function App() {
  const [value, setValue] = useState(initialValue);
  const [disabled, setDisabled] = useState(false);

  return (
    <WidgetHost
      widgetFn={MyWidget}
      value={value}
      onChange={setValue}
      disabled={disabled}
    />
  );
}

App.jsx

以下を提供するメインのテストハーネスです:

  • ウィジェットのマウント: WidgetHost を介してウィジェットをインポートしてレンダリング
  • 状態コントロール: 無効化(disabled)状態を切り替えるチェックボックス
  • デバッグパネル: 現在のウィジェットの状態の JSON 表示(チェックボックスで切り替え)
  • リセット機能: ウィジェットを初期状態にリセットするボタン
  • 視覚的レイアウト: Griptape Nodes の美学に合わせたクリーンなダークテーマ UI

ウィジェットのテスト手順 (Testing a Widget)

1. 依存関係のインストール:

cd widget-testbed
npm install

2. ウィジェットをインポートするように App.jsx を更新:

import MyWidget from "../../my-library/widgets/MyWidget.js";

const INITIAL_VALUE = { /* 初期状態 */ };

export default function App() {
  const [value, setValue] = useState(INITIAL_VALUE);
  const [disabled, setDisabled] = useState(false);
  const [showDebug, setShowDebug] = useState(true);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
      <h1 style={{ fontSize: 18, fontWeight: 600, color: "#eee" }}>
        MyWidget テストベッド
      </h1>

      <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
        <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
          <input
            type="checkbox"
            checked={disabled}
            onChange={(e) => setDisabled(e.target.checked)}
          />
          無効化 (Disabled)
        </label>
        <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
          <input
            type="checkbox"
            checked={showDebug}
            onChange={(e) => setShowDebug(e.target.checked)}
          />
          JSON を表示
        </label>
        <button
          onClick={() => setValue(INITIAL_VALUE)}
          style={{
            padding: "4px 12px",
            fontSize: 12,
            background: "#333",
            border: "1px solid #555",
            borderRadius: 4,
            color: "#ccc",
            cursor: "pointer",
          }}
        >
          リセット
        </button>
      </div>

      <div
        style={{
          border: "1px solid #333",
          borderRadius: 8,
          overflow: "hidden",
        }}
      >
        <WidgetHost
          widgetFn={MyWidget}
          value={value}
          onChange={setValue}
          disabled={disabled}
        />
      </div>

      {showDebug && (
        <pre
          style={{
            background: "#1a1a1a",
            border: "1px solid #333",
            borderRadius: 8,
            padding: 12,
            fontSize: 11,
            color: "#8c8",
            overflow: "auto",
            maxHeight: 300,
          }}
        >
          {JSON.stringify(value, null, 2)}
        </pre>
      )}
    </div>
  );
}

3. 開発サーバーの起動:

npm run dev

4. ブラウザで開く:

http://localhost:5173(またはターミナルに表示されたポート)にアクセスします。

開発ワークフロー (Development Workflow)

典型的な開発サイクル:

  1. ウィジェットコードの記述: ウィジェットの .js ファイルを作成または変更します
  2. テストベッドの更新: App.jsx でウィジェットをインポートします
  3. 開発サーバーの実行: ホットリロード用に npm run dev を実行します
  4. 操作のテスト: ウィジェットのクリック、入力、ドラッグなどのインタラクションをテストします
  5. 状態の検証: JSON デバッグパネルをチェックして状態の変化を確認します
  6. エッジケースのテスト: リセットボタンや Disabled トグルを使用してエッジケースをテストします
  7. イテレーション: ウィジェットのコードを修正し、即座に更新結果を確認します

一般的なテストシナリオ:

  • フォーカス管理: テキストフィールドに入力し、onChange でフォーカスが失われないことを確認
  • ドラッグ&ドロップ: 並べ替えをテストし、アイテムの識別情報が維持されることを確認
  • 状態遷移: アイテムの追加/削除を行い、正しい状態更新が行われることを確認
  • 無効化モード: disabled を切り替え、ウィジェットが読み取り専用になることを確認
  • 外部からの状態変更: リセットボタンを使用して、ウィジェットが外部の更新を正しく処理できるか確認
  • イベント伝播: クリックやドラッグが親要素に干渉しないことを確認(nodrag クラスの使用)
  • キーボードショートカット: Delete や Ctrl+C などがノードレベルのアクションをトリガーしないことをテスト

WidgetHost パターンの詳細

WidgetHost コンポーネントは重要な課題を解決します: ウィジェット自身が onChange をトリガーしたときに、不要なウィジェットの再マウントを防ぐこと です。これがなければ、キーストロークのたびにウィジェットが破棄・再作成され、フォーカスや内部状態が失われてしまいます。

仕組み:

  1. フラグによる変更追跡: isWidgetChangeRef は、現在の変更がウィジェット内部から発生したかどうかを追跡します
  2. 条件付き再マウント: ウィジェットは、value が外部から変更された場合にのみ再マウントされます(onChange 経由の場合は再マウントをスキップ)
  3. 安定した onChange コールバック: 不要な effect のトリガーを防ぐために useCallback を使用します
  4. アンマウント時のクリーンアップ: ウィジェットが破棄されるか、外部の値変更時にウィジェットのクリーンアップ関数を呼び出します

主要な実装コード:

const isWidgetChangeRef = useRef(false);

const stableOnChange = useCallback(
  (newValue) => {
    isWidgetChangeRef.current = true;  // ウィジェット発の変更としてフラグを立てる
    onChange?.(newValue);
  },
  [onChange],
);

useEffect(() => {
  if (isWidgetChangeRef.current) {
    isWidgetChangeRef.current = false;  // フラグをクリアして再マウントをスキップ
    return;
  }

  // 外部からの値変更: ウィジェットを再マウント
  const cleanup = widgetFn(container, { value, onChange: stableOnChange, disabled, height });
  return cleanup;
}, [widgetFn, value, disabled, height, stableOnChange]);

このパターンにより、ウィジェットは onChange 呼び出しをまたいで内部 DOM と状態を維持でき、フォーカス喪失や再マウントによる諸問題を回避できます。

ベストプラクティス (Best Practices)

widget-testbed の利用時:

  • 本番環境の props と一致させる: Griptape Nodes と同じ prop 名(value、onChange、disabled、height)を使用してください
  • disabled 状態をテストする: ウィジェットが disabled prop を適切に尊重しているか必ず検証してください
  • クリーンアップを検証する: ウィジェットのクリーンアップ関数がイベントリスナーを適切に削除しているか確認してください
  • エッジケースをテストする: リセットボタンを使用して、ウィジェットが外部の値変更をどのように処理するかテストしてください
  • 状態を検査する: 状態の流れを理解するために、JSON デバッグパネルを表示したまま作業してください
  • キーボードイベントをテストする: keydown での stopPropagation がノードレベルのショートカットを阻止しているか確認してください
  • マウスイベントをテストする: pointerdown/mousedown での stopPropagation がノードのドラッグを阻止しているか確認してください
  • クローン作成を検証する: onChange が内部状態への参照ではなく、クローンされたデータを受け取っているか確認してください

避けるべきこと:

  • widget-testbed/ をライブラリリポジトリにコミットしない(これはローカル開発用ツールです)
  • 本番固有の機能(ノード接続、ワークフロー実行など)をテストベッドでテストしようとしない
  • テストベッドの動作が本番環境と完全に同一であると思い込まない(最終テストは常に Griptape Nodes 上で実施してください)

実装例: MultiShotEditor テストベッド

Kling ライブラリの MultiShotEditor ウィジェットをテストする構成例:

import MultiShotEditor from "../../kling/widgets/MultiShotEditor.js";

const INITIAL_SHOTS = [{ name: "Shot1", duration: 2, description: "" }];

export default function App() {
  const [shots, setShots] = useState(INITIAL_SHOTS);
  // ... コントロールおよびデバッグ UI ...

  return (
    <WidgetHost
      widgetFn={MultiShotEditor}
      value={shots}
      onChange={setShots}
      disabled={disabled}
    />
  );
}

これは、ドラッグ&ドロップによる並べ替え、追加/削除機能、複数のテキスト入力を備えたショットオブジェクトの配列を管理する複雑なウィジェットに対するテストベッドの実装パターンを示しています。