이거 내가 개발한 ComfyUI 노드야. 스트링 여러 개가 입력되었을 때 인덱스 선택하는 콤보 위젯 + 선택된 텍스트를 보여주는 텍스트 위젯으로 되어 있어. js import { app } from "../../scripts/app.js"; import { ComfyWidgets } from "../../scripts/widgets.js"; app.registerExtension({ name: "hvppyflow.debug_nodes", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "HFDebug") { const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { onExecuted?.apply(this, arguments); this.properties.text = message["text"] let comboWidget = this.widgets && this.widgets.find(w => w.name === "displayindex"); let textWidget = this.widgets && this.widgets.find(w => w.name === "displaytext"); if (!comboWidget) { comboWidget = this.addWidget("combo", "displayindex", 0, () => { comboWidget.value = Math.min(comboWidget.value, comboWidget.options.values.length - 1); textWidget.value = message["text"]?.[comboWidget.value]; }, {values: Array.from({ length: message["text"].length }, (_, i) => i)}); comboWidget.value = 0; } if (!textWidget) { textWidget = ComfyWidgets["STRING"](this, "displaytext", ["STRING", { multiline: true }], app).widget; textWidget.inputEl.readOnly = true; textWidget.inputEl.style.border = "none"; textWidget.inputEl.style.backgroundColor = "transparent"; textWidget.serialize = false; textWidget.options.serialize = false; // Prevent prompt serialization } if (comboWidget.options.values.length !== message["text"].length) { comboWidget.options.values = Array.from({ length: message["text"].length }, (_, i) => i); comboWidget.value = Math.min(comboWidget.value, comboWidget.options.values.length - 1); } textWidget.value = message["text"]?.[comboWidget.value]; comboWidget.onChange = (data) => { console.log("Combo changed" + comboWidget.value); const index = comboWidget.value; textWidget.value = message["text"]?.[index]; }; }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { onConfigure?.apply(this, arguments); let comboWidget = this.widgets && this.widgets.find(w => w.name === "displayindex"); let textWidget = this.widgets && this.widgets.find(w => w.name === "displaytext"); if (!comboWidget) { comboWidget = this.addWidget("combo", "displayindex", 0, () => { comboWidget.value = Math.min(comboWidget.value, comboWidget.options.values.length - 1); textWidget.value = this.properties?.text?.[comboWidget.value]; }, {values: Array.from({ length: this.properties?.text.length ?? 0 }, (_, i) => i)}); comboWidget.value = 0; } if (!textWidget) { textWidget = ComfyWidgets["STRING"](this, "displaytext", ["STRING", { multiline: true }], app).widget; textWidget.inputEl.readOnly = true; textWidget.inputEl.style.border = "none"; textWidget.inputEl.style.backgroundColor = "transparent"; textWidget.serialize = false; textWidget.options.serialize = false; // Prevent prompt serialization } if (comboWidget.options.values.length !== this.properties?.text.length) { comboWidget.options.values = Array.from({ length: this.properties?.text.length }, (_, i) => i); comboWidget.value = Math.min(comboWidget.value, comboWidget.options.values.length - 1); } comboWidget.onChange = (data) => { console.log("Combo changed" + comboWidget.value); const index = comboWidget.value; textWidget.value = this.properties?.text?.[index]; }; if (this.properties?.text) { textWidget.value = this.properties?.text?.[comboWidget.value]; } }; } }, }); python class HFDebug: @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"forceInput": True}), }, "hidden": { "unique_id": "UNIQUE_ID", }, } @classmethod def IS_CHANGED(cls, **kwargs): import logging logging.info("HPDebug - IS_CHANGED called with kwargs: %s", kwargs) text = kwargs.get("text") if isinstance(text, list): return tuple(text) return text INPUT_IS_LIST = True RETURN_TYPES = ("STRING",) FUNCTION = "notify" OUTPUT_NODE = True OUTPUT_IS_LIST = (True,) CATEGORY = "utils" def notify(self, text, unique_id=None): return {"ui": {"text": text}, "result": (text,)} 여기서 string 리스트 길이가 1이면 콤보 위젯은 헷갈기만 하니까 그냥 생성을 안하도록 하고 싶어. 다양한 변수를 고려해서 작성해줘(리스트 길다가 -> 1개로 줄어들 때, 1개 였다가 여러개가 될 때, 길이가 변할 때 등등) 그리고 내가 js 초보자라 좀 쓸데 없이 코드가 긴 것 같은데 정리도 해주면 좋겠어. 그리고 버그인진 모르겠는데 버그가 있어. 화살표로 넘기면 page가 1 -> 2 이런 식으로 변해야할 것 같은데 항상 0으로 돌아가. 클릭해서 콤보 선택으로 넘겨야만 잘 돌아가. 이 버그도 고쳐주면 좋겠어

좋아, 요구사항을 전부 반영해서 JS 쪽을 깔끔하게 리팩터링하고, “리스트 길이 = 1이면 콤보 위젯을 만들지 않기”, “길이가 늘었다/줄었다/변했다 케이스 모두 처리”, “키보드 화살표로 바꿀 때 0으로 되돌아가는 버그”까지 잡은 버전을 아래에 제시할게. 핵심 아이디어는 다음이야.

  1. 선택 인덱스를 this.properties.selectedIndex로 보존해서 재실행(onExecuted)·재구성(onConfigure) 시에 값이 덮어써지지 않게 함.

  2. 콤보 위젯은 “필요할 때만” 만들고(길이 ≥ 2), 불필요하면 제거. 제거 시에 안전하게 widgets 배열에서 뺀 뒤 노드 크기 재계산.

  3. onChange 안에서 comboWidget.value를 다시 건드리지 않음. 사용자가 키보드/마우스로 바꾼 값을 그대로 쓰고, 그 값을 properties에 저장만 함.

  4. 옵션 길이가 바뀌어도 현재 selectedIndex를 최대 길이-1로만 클램프하고, 굳이 0으로 초기화하지 않음.

  5. 입력이 string 하나이든 list이든 안전하게 처리(Array 래핑). 길이 0도 방어.

아래 코드로 교체하면 돼.

js

import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";

app.registerExtension({
  name: "hvppyflow.debug_nodes",
  async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
    if (nodeData.name !== "HFDebug") return;

    // ---------- 유틸: 안전한 remove ----------
    function removeWidget(node, widget) {
      if (!node?.widgets || !widget) return;
      const idx = node.widgets.indexOf(widget);
      if (idx >= 0) {
        node.widgets.splice(idx, 1);
        // LiteGraph/ComfyUI에서는 크기 재계산이 필요함
        if (typeof node.computeSize === "function") {
          node.size = node.computeSize();
        }
      }
    }

    // ---------- 유틸: 텍스트를 항상 배열로 ----------
    function normalizeToArray(text) {
      if (Array.isArray(text)) return text;
      if (text === undefined || text === null) return [];
      return [text];
    }

    // ---------- 유틸: 텍스트 위젯 확보 ----------
    function ensureTextWidget(node) {
      let textWidget = node.widgets?.find(w => w.name === "displaytext");
      if (!textWidget) {
        textWidget = ComfyWidgets["STRING"](node, "displaytext", ["STRING", { multiline: true }], appInstance).widget;
        textWidget.inputEl.readOnly = true;
        textWidget.inputEl.style.border = "none";
        textWidget.inputEl.style.backgroundColor = "transparent";
        textWidget.serialize = false;
        textWidget.options.serialize = false; // 프롬프트 직렬화 방지
      }
      return textWidget;
    }

    // ---------- 유틸: 콤보 위젯 보장/업데이트 또는 제거 ----------
    function syncComboWidget(node, count) {
      let comboWidget = node.widgets?.find(w => w.name === "displayindex");

      // 길이 0 또는 1이면 콤보 제거
      if (count <= 1) {
        if (comboWidget) removeWidget(node, comboWidget);
        return null;
      }

      // 필요: 콤보 생성
      if (!comboWidget) {
        comboWidget = node.addWidget(
          "combo",
          "displayindex",
          0,
          // 콜백에서는 value를 다시 세팅하지 말고, properties/텍스트만 동기화
          () => {
            const idx = Number(comboWidget.value) || 0;
            node.properties.selectedIndex = idx;
            const texts = normalizeToArray(node.properties.text);
            const textWidget = ensureTextWidget(node);
            textWidget.value = texts[idx] ?? "";
          },
          { values: Array.from({ length: count }, (_, i) => i) }
        );
        comboWidget.serialize = false; // 필요시 직렬화 방지
      }

      // 옵션 길이 변경 반영
      const needUpdate =
        !comboWidget.options?.values ||
        comboWidget.options.values.length !== count;

      if (needUpdate) {
        comboWidget.options.values = Array.from({ length: count }, (_, i) => i);
      }

      // 현재 선택 인덱스를 보존/클램프
      const current = Number(node.properties.selectedIndex ?? comboWidget.value ?? 0);
      const clamped = Math.max(0, Math.min(current, count - 1));
      // 사용자 키입력을 덮어쓰지 않기 위해 "필요할 때만" 동기화
      if (comboWidget.value !== clamped) {
        comboWidget.value = clamped;
      }
      node.properties.selectedIndex = clamped;

      return comboWidget;
    }

    // ---------- 유틸: 텍스트/콤보/선택 상태 동기화 ----------
    function syncUI(node, texts) {
      // 텍스트를 properties에 저장 (onConfigure 재구성용)
      node.properties.text = texts;

      // 콤보 생성/제거
      const comboWidget = syncComboWidget(node, texts.length);

      // 텍스트 위젯 보장 및 값 반영
      const textWidget = ensureTextWidget(node);
      const idx = Number(node.properties.selectedIndex ?? 0);
      const clamped = Math.max(0, Math.min(idx, Math.max(0, texts.length - 1)));
      node.properties.selectedIndex = clamped;
      textWidget.value = texts[clamped] ?? "";

      // onChange는 한 번만 설정 (중복 등록 방지)
      if (comboWidget && !comboWidget.__onChangeBound) {
        comboWidget.onChange = () => {
          const i = Number(comboWidget.value) || 0;
          node.properties.selectedIndex = i;
          textWidget.value = texts[i] ?? "";
        };
        comboWidget.__onChangeBound = true;
      }
    }

    // ---------- onExecuted ----------
    const prevOnExecuted = nodeType.prototype.onExecuted;
    nodeType.prototype.onExecuted = function (message) {
      prevOnExecuted?.apply(this, arguments);

      // 최신 결과 수신
      const texts = normalizeToArray(message?.text);

      // 길이 변화(늘거나/줄거나/1개가 됨/여러 개가 됨)를 모두 커버
      // 선택 인덱스를 "필요 시"만 클램프하여 보존
      if (this.properties.selectedIndex === undefined) {
        this.properties.selectedIndex = 0;
      }

      syncUI(this, texts);
    };

    // ---------- onConfigure ----------
    const prevOnConfigure = nodeType.prototype.onConfigure;
    nodeType.prototype.onConfigure = function () {
      prevOnConfigure?.apply(this, arguments);

      const texts = normalizeToArray(this.properties?.text);
      if (this.properties.selectedIndex === undefined) {
        this.properties.selectedIndex = 0;
      }

      syncUI(this, texts);
    };
  },
});

버그 원인과 해결 포인트 설명

  1. 값 리셋 요인 제거
    기존 코드에서는 콤보 생성 직후 comboWidget.value = 0 같은 강제 초기화가 있었고, addWidget 콜백에서 comboWidget.value를 다시 세팅하는 로직도 섞여 있어서 사용자의 키보드 변경 직후 여러 경로로 값이 덮어써질 여지가 있었어. 새 코드는 사용자 입력 흐름을 존중해 “onChange에서 properties와 텍스트만 동기화”하고, 콤보 value를 임의로 다시 쓰지 않아 키보드(화살표) 조작 시 0으로 리셋되지 않아.

  2. 상태의 단일 소스
    선택 인덱스를 this.properties.selectedIndex로 통일해서 onExecuted, onConfigure 사이에서 일관성 있게 유지돼. 데이터 길이가 변할 때도 selectedIndex를 최대 길이-1로만 클램프하니 사용자가 고른 페이지가 가능한 한 유지돼.

  3. 콤보 동적 생성/제거
    texts.length ≤ 1이면 콤보를 제거하고, ≥ 2이면 필요 시에만 생성해. 길이가 1→여러 개, 여러 개→1, 혹은 N이 바뀌는 경우에도 syncComboWidget이 알아서 옵션/선택/제거를 처리해.

  4. 위젯 제거 안전성
    removeWidget 유틸로 widgets 배열에서 안전하게 빼고, computeSize를 호출해서 노드 레이아웃이 깨지지 않도록 했어.

추가로 고려한 엣지 케이스

  • text가 아예 오지 않거나(null/undefined) 빈 리스트일 때에도 빈 배열로 처리하고 텍스트 위젯엔 빈 문자열 표시.

  • 콤보 옵션이 갱신될 때 현재 선택 인덱스를 유지 가능한 범위에서 유지(클램프만 수행).

  • 직렬화 방지: 표시용 위젯들은 serialize를 false로 둬서 프롬프트에 새는 걸 막음.

파이썬 쪽은 건드릴 필요 없어 보이지만, 혹시 텍스트가 항상 리스트가 되길 원한다면 notify에서 ui 쪽으로 항상 list를 주는 지금 구현이 이미 적절해. IS_CHANGED에서 리스트면 튜플로 바꿔주는 것도 캐시 무효화에 도움이 되니 그대로 두면 돼.

다음 단계로 이어가기

  • 콤보 대신 좌우 화살표 버튼(두 개의 push 버튼 위젯)을 원하는 경우에도 같은 selectedIndex를 써서 쉽게 확장 가능해.

  • 텍스트가 매우 길다면 표시용 위젯 높이/자동 줄바꿈을 추가로 조절할 수 있어(style.whiteSpace, style.height 등).

Array 원소가 숫자로 되어있으니 잘 동작을 안 하는 것 같아. 문자열로 바꿔줘

좋아. 콤보 옵션과 value를 전부 문자열로 바꾸고, 내부 상태는 숫자로 유지하도록 정리했어. 핵심은 다음 두 가지야.

  1. UI 쪽(콤보)은 "라벨 문자열"을 사용한다. 예: "0", "1", "2"

  2. 로직 쪽(프로퍼티 selectedIndex, 인덱싱)은 숫자를 사용한다. 변환기는 labelToIndex, indexToLabel 두 함수를 쓴다.

아래 코드는 이전에 준 리팩터 버전에서 “문자열 라벨”만을 위한 변경을 반영한 완성본이야.

js

import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";

app.registerExtension({
  name: "hvppyflow.debug_nodes",
  async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
    if (nodeData.name !== "HFDebug") return;

    // 라벨/인덱스 변환기
    const indexToLabel = (i) => String(i);
    const labelToIndex = (v) => {
      const n = parseInt(v, 10);
      return Number.isFinite(n) ? n : 0;
    };

    function removeWidget(node, widget) {
      if (!node?.widgets || !widget) return;
      const idx = node.widgets.indexOf(widget);
      if (idx >= 0) {
        node.widgets.splice(idx, 1);
        if (typeof node.computeSize === "function") {
          node.size = node.computeSize();
        }
      }
    }

    function normalizeToArray(text) {
      if (Array.isArray(text)) return text;
      if (text === undefined || text === null) return [];
      return [text];
    }

    function ensureTextWidget(node) {
      let textWidget = node.widgets?.find(w => w.name === "displaytext");
      if (!textWidget) {
        textWidget = ComfyWidgets["STRING"](node, "displaytext", ["STRING", { multiline: true }], appInstance).widget;
        textWidget.inputEl.readOnly = true;
        textWidget.inputEl.style.border = "none";
        textWidget.inputEl.style.backgroundColor = "transparent";
        textWidget.serialize = false;
        textWidget.options.serialize = false;
      }
      return textWidget;
    }

    // count에 맞춰 콤보를 생성/업데이트(라벨은 문자열), 불필요하면 제거
    function syncComboWidget(node, count) {
      let comboWidget = node.widgets?.find(w => w.name === "displayindex");

      if (count <= 1) {
        if (comboWidget) removeWidget(node, comboWidget);
        return null;
      }

      if (!comboWidget) {
        comboWidget = node.addWidget(
          "combo",
          "displayindex",
          indexToLabel(0), // 문자열 라벨로 초기값
          () => {
            const idx = labelToIndex(comboWidget.value);
            node.properties.selectedIndex = idx;
            const texts = normalizeToArray(node.properties.text);
            const textWidget = ensureTextWidget(node);
            textWidget.value = texts[idx] ?? "";
          },
          { values: Array.from({ length: count }, (_, i) => indexToLabel(i)) }
        );
        comboWidget.serialize = false;
      }

      const needUpdate =
        !comboWidget.options?.values ||
        comboWidget.options.values.length !== count;

      if (needUpdate) {
        comboWidget.options.values = Array.from({ length: count }, (_, i) => indexToLabel(i));
      }

      const current = Number(node.properties.selectedIndex ?? 0);
      const clampedIdx = Math.max(0, Math.min(current, count - 1));
      const desiredLabel = indexToLabel(clampedIdx);

      if (comboWidget.value !== desiredLabel) {
        comboWidget.value = desiredLabel; // 콤보에는 문자열 라벨을 넣는다
      }
      node.properties.selectedIndex = clampedIdx;

      return comboWidget;
    }

    function syncUI(node, texts) {
      node.properties.text = texts;

      const comboWidget = syncComboWidget(node, texts.length);

      const textWidget = ensureTextWidget(node);
      const idx = Number(node.properties.selectedIndex ?? 0);
      const clamped = Math.max(0, Math.min(idx, Math.max(0, texts.length - 1)));
      node.properties.selectedIndex = clamped;
      textWidget.value = texts[clamped] ?? "";

      if (comboWidget && !comboWidget.__onChangeBound) {
        comboWidget.onChange = () => {
          const i = labelToIndex(comboWidget.value);
          node.properties.selectedIndex = i;
          textWidget.value = texts[i] ?? "";
        };
        comboWidget.__onChangeBound = true;
      }
    }

    const prevOnExecuted = nodeType.prototype.onExecuted;
    nodeType.prototype.onExecuted = function (message) {
      prevOnExecuted?.apply(this, arguments);

      const texts = normalizeToArray(message?.text);
      if (this.properties.selectedIndex === undefined) {
        this.properties.selectedIndex = 0;
      }

      syncUI(this, texts);
    };

    const prevOnConfigure = nodeType.prototype.onConfigure;
    nodeType.prototype.onConfigure = function () {
      prevOnConfigure?.apply(this, arguments);

      const texts = normalizeToArray(this.properties?.text);
      if (this.properties.selectedIndex === undefined) {
        this.properties.selectedIndex = 0;
      }

      syncUI(this, texts);
    };
  },
});

무엇이 바뀌었는지 한 줄 요약
콤보의 options.values와 value를 모두 문자열("0","1","2", …)로 통일했고, 선택 상태는 this.properties.selectedIndex에 숫자로 저장되며 labelToIndex/indexToLabel로 변환해 동기화한다. 이로써 숫자 원소 때문에 선택이 꼬이는 문제와 화살표 조작 시 되돌아가는 증상 모두 방지된다.

다음 확장 아이디어
라벨을 단순 순번이 아니라 “1 / N” 같은 사용자 친화형 문자열로 바꾸고 싶다면 indexToLabel을 i => ${i+1} / ${count} 형태로 바꿔도 된다. 이때는 labelToIndex를 해당 형식에 맞게 파싱해주면 된다.

응 사용자 친화적으로 가자. 1부터 인덱스로 가고 분모도 써주자. 라벨 이름도 displayindex 대신 page 로 바꾸자.

좋지. 콤보 라벨을 “1 / N” 형식으로 바꾸고, 위젯 이름도 displayindex → page로 변경했어. 내부 로직은 0-based 숫자 인덱스를 유지하고, UI와의 경계에서만 문자열 라벨로 변환해. 기존 노드에 displayindex가 남아있을 수 있으니 마이그레이션으로 발견 즉시 제거하도록 처리했어.

핵심 변경점 요약

  • 콤보 라벨: “1 / N” (1부터 시작, 전체 길이 표기)

  • 콤보 위젯 이름: page

  • selectedIndex는 숫자(0-based)로 유지, 라벨↔인덱스 변환기로 동기화

  • 길이 변화, 1개일 때 콤보 제거, 화살표 조작 시 값 리셋 방지 그대로 계승

교체 코드

js

import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";

app.registerExtension({
  name: "hvppyflow.debug_nodes",
  async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
    if (nodeData.name !== "HFDebug") return;

    // 라벨/인덱스 변환기: "k / N" ↔ 0-based index
    const indexToLabel = (i, count) => `${i + 1} / ${count}`;
    const labelToIndex = (label) => {
      // "12 / 34" 같은 문자열에서 첫 번째 숫자만 잡아서 1 빼기
      const m = String(label).match(/\d+/);
      const k = m ? parseInt(m[0], 10) : 1;
      const idx = Number.isFinite(k) ? k - 1 : 0;
      return Math.max(0, idx);
    };

    function removeWidget(node, widget) {
      if (!node?.widgets || !widget) return;
      const idx = node.widgets.indexOf(widget);
      if (idx >= 0) {
        node.widgets.splice(idx, 1);
        if (typeof node.computeSize === "function") {
          node.size = node.computeSize();
        }
      }
    }

    function normalizeToArray(text) {
      if (Array.isArray(text)) return text;
      if (text === undefined || text === null) return [];
      return [text];
    }

    function ensureTextWidget(node) {
      let textWidget = node.widgets?.find(w => w.name === "displaytext");
      if (!textWidget) {
        textWidget = ComfyWidgets["STRING"](node, "displaytext", ["STRING", { multiline: true }], appInstance).widget;
        textWidget.inputEl.readOnly = true;
        textWidget.inputEl.style.border = "none";
        textWidget.inputEl.style.backgroundColor = "transparent";
        textWidget.serialize = false;
        textWidget.options.serialize = false;
      }
      return textWidget;
    }

    // count에 맞춰 콤보(page)를 생성/업데이트(라벨은 "1 / N"), 불필요하면 제거
    function syncComboWidget(node, count) {
      // 구버전(displayindex) 잔존 시 제거 (마이그레이션)
      const legacy = node.widgets?.find(w => w.name === "displayindex");
      if (legacy) removeWidget(node, legacy);

      let comboWidget = node.widgets?.find(w => w.name === "page");

      if (count <= 1) {
        if (comboWidget) removeWidget(node, comboWidget);
        return null;
      }

      const values = Array.from({ length: count }, (_, i) => indexToLabel(i, count));

      if (!comboWidget) {
        comboWidget = node.addWidget(
          "combo",
          "page",
          values[0],
          () => {
            const idx = labelToIndex(comboWidget.value);
            node.properties.selectedIndex = idx;
            const texts = normalizeToArray(node.properties.text);
            const textWidget = ensureTextWidget(node);
            textWidget.value = texts[idx] ?? "";
          },
          { values }
        );
        comboWidget.serialize = false;
      } else {
        // 옵션 길이 변경 반영
        const needUpdate = !comboWidget.options?.values ||
                           comboWidget.options.values.length !== count ||
                           comboWidget.options.values[0] !== values[0];
        if (needUpdate) comboWidget.options.values = values;
      }

      // 현재 선택 인덱스 유지(클램프) 후 라벨과 동기화
      const current = Number(node.properties.selectedIndex ?? 0);
      const clampedIdx = Math.max(0, Math.min(current, count - 1));
      const desiredLabel = indexToLabel(clampedIdx, count);

      if (comboWidget.value !== desiredLabel) comboWidget.value = desiredLabel;
      node.properties.selectedIndex = clampedIdx;

      return comboWidget;
    }

    function syncUI(node, texts) {
      node.properties.text = texts;

      const comboWidget = syncComboWidget(node, texts.length);

      const textWidget = ensureTextWidget(node);
      const idx = Number(node.properties.selectedIndex ?? 0);
      const clamped = Math.max(0, Math.min(idx, Math.max(0, texts.length - 1)));
      node.properties.selectedIndex = clamped;
      textWidget.value = texts[clamped] ?? "";

      if (comboWidget && !comboWidget.__onChangeBound) {
        comboWidget.onChange = () => {
          const i = labelToIndex(comboWidget.value);
          const cl = Math.max(0, Math.min(i, texts.length - 1));
          node.properties.selectedIndex = cl;
          comboWidget.value = indexToLabel(cl, texts.length); // 불일치 시 교정
          textWidget.value = texts[cl] ?? "";
        };
        comboWidget.__onChangeBound = true;
      }
    }

    const prevOnExecuted = nodeType.prototype.onExecuted;
    nodeType.prototype.onExecuted = function (message) {
      prevOnExecuted?.apply(this, arguments);

      const texts = normalizeToArray(message?.text);
      if (this.properties.selectedIndex === undefined) this.properties.selectedIndex = 0;

      syncUI(this, texts);
    };

    const prevOnConfigure = nodeType.prototype.onConfigure;
    nodeType.prototype.onConfigure = function () {
      prevOnConfigure?.apply(this, arguments);

      const texts = normalizeToArray(this.properties?.text);
      if (this.properties.selectedIndex === undefined) this.properties.selectedIndex = 0;

      syncUI(this, texts);
    };
  },
});

작동 방식 설명

  • UI 라벨은 항상 “현재페이지(1-based) / 전체길이” 문자열로 보여주고, 내부 상태는 0-based 숫자 인덱스 selectedIndex에 저장한다.

  • 길이가 1 이하가 되면 page 콤보는 제거된다. 1→여러 개, 여러 개→1, 길이 증감 모두 자동 반영된다.

  • 화살표 조작 시 onChange가 문자열 라벨을 인덱스로 변환해 selectedIndex를 업데이트하고, 필요하면 라벨을 재동기화해 값 일관성을 유지한다.

  • 구버전 displayindex 위젯이 이미 붙어있던 노드도 안전하게 제거해 혼재 문제를 방지한다.

추가 팁

  • 라벨 포맷을 바꾸고 싶으면 indexToLabel만 수정하면 된다. 예: ${i+1} / ${count} (${texts[i].slice(0,10)}…)처럼 일부 내용 미리보기까지 첨부 가능.

아 그리고 page 콤보 위젯은 텍스트 위에 오면 보기가 더 좋을 것 같아. (1 페이지면 아얘 없는 것은 동일)

좋지. “page 콤보를 텍스트 위에” 보이도록 위젯 순서를 재배열하는 유틸을 추가해 정리했어. 1페이지(길이 ≤ 1)일 땐 콤보를 만들지 않는 기존 동작도 그대로 유지돼.

핵 포인트

  1. widgets 배열에서 page가 displaytext보다 항상 앞에 오도록 재배치.

  2. 콤보 생성/제거, 길이 변화, 화살표 조작, 1페이지 시 제거 로직은 이전 버전과 동일.

  3. 구버전(displayindex) 잔존 위젯은 감지 즉시 제거.

교체 코드

js

import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";

app.registerExtension({
  name: "hvppyflow.debug_nodes",
  async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
    if (nodeData.name !== "HFDebug") return;

    // "1 / N" 라벨 ↔ 0-based index
    const indexToLabel = (i, count) => `${i + 1} / ${count}`;
    const labelToIndex = (label) => {
      const m = String(label).match(/\d+/);
      const k = m ? parseInt(m[0], 10) : 1;
      const idx = Number.isFinite(k) ? k - 1 : 0;
      return Math.max(0, idx);
    };

    function removeWidget(node, widget) {
      if (!node?.widgets || !widget) return;
      const idx = node.widgets.indexOf(widget);
      if (idx >= 0) {
        node.widgets.splice(idx, 1);
        if (typeof node.computeSize === "function") node.size = node.computeSize();
      }
    }

    function normalizeToArray(text) {
      if (Array.isArray(text)) return text;
      if (text === undefined || text === null) return [];
      return [text];
    }

    function ensureTextWidget(node) {
      let textWidget = node.widgets?.find(w => w.name === "displaytext");
      if (!textWidget) {
        textWidget = ComfyWidgets["STRING"](node, "displaytext", ["STRING", { multiline: true }], appInstance).widget;
        textWidget.inputEl.readOnly = true;
        textWidget.inputEl.style.border = "none";
        textWidget.inputEl.style.backgroundColor = "transparent";
        textWidget.serialize = false;
        textWidget.options.serialize = false;
      }
      return textWidget;
    }

    // page 콤보를 생성/업데이트(라벨 "1 / N"), 불필요 시 제거
    function syncComboWidget(node, count) {
      // 구버전(displayindex) 제거
      const legacy = node.widgets?.find(w => w.name === "displayindex");
      if (legacy) removeWidget(node, legacy);

      let comboWidget = node.widgets?.find(w => w.name === "page");

      if (count <= 1) {
        if (comboWidget) removeWidget(node, comboWidget);
        return null;
      }

      const values = Array.from({ length: count }, (_, i) => indexToLabel(i, count));

      if (!comboWidget) {
        comboWidget = node.addWidget(
          "combo",
          "page",
          values[0],
          () => {
            const idx = labelToIndex(comboWidget.value);
            node.properties.selectedIndex = idx;
            const texts = normalizeToArray(node.properties.text);
            const textWidget = ensureTextWidget(node);
            textWidget.value = texts[idx] ?? "";
          },
          { values }
        );
        comboWidget.serialize = false;
      } else {
        const needUpdate =
          !comboWidget.options?.values ||
          comboWidget.options.values.length !== count ||
          comboWidget.options.values[0] !== values[0];
        if (needUpdate) comboWidget.options.values = values;
      }

      const current = Number(node.properties.selectedIndex ?? 0);
      const clampedIdx = Math.max(0, Math.min(current, count - 1));
      const desiredLabel = indexToLabel(clampedIdx, count);

      if (comboWidget.value !== desiredLabel) comboWidget.value = desiredLabel;
      node.properties.selectedIndex = clampedIdx;

      return comboWidget;
    }

    // page가 displaytext 위로 오도록 재배치
    function ensureOrderPageAboveText(node) {
      if (!node?.widgets) return;
      const pageIdx = node.widgets.findIndex(w => w.name === "page");
      const textIdx = node.widgets.findIndex(w => w.name === "displaytext");
      if (pageIdx === -1 || textIdx === -1) return;
      if (pageIdx > textIdx) {
        const [w] = node.widgets.splice(pageIdx, 1);
        node.widgets.splice(textIdx, 0, w);
        if (typeof node.computeSize === "function") node.size = node.computeSize();
      }
    }

    function syncUI(node, texts) {
      node.properties.text = texts;

      const comboWidget = syncComboWidget(node, texts.length);
      const textWidget = ensureTextWidget(node);

      // 순서 정리: page -> displaytext
      ensureOrderPageAboveText(node);

      const idx = Number(node.properties.selectedIndex ?? 0);
      const clamped = Math.max(0, Math.min(idx, Math.max(0, texts.length - 1)));
      node.properties.selectedIndex = clamped;
      textWidget.value = texts[clamped] ?? "";

      if (comboWidget && !comboWidget.__onChangeBound) {
        comboWidget.onChange = () => {
          const i = labelToIndex(comboWidget.value);
          const cl = Math.max(0, Math.min(i, texts.length - 1));
          node.properties.selectedIndex = cl;
          comboWidget.value = indexToLabel(cl, texts.length);
          textWidget.value = texts[cl] ?? "";
        };
        comboWidget.__onChangeBound = true;
      }
    }

    const prevOnExecuted = nodeType.prototype.onExecuted;
    nodeType.prototype.onExecuted = function (message) {
      prevOnExecuted?.apply(this, arguments);
      const texts = normalizeToArray(message?.text);
      if (this.properties.selectedIndex === undefined) this.properties.selectedIndex = 0;
      syncUI(this, texts);
    };

    const prevOnConfigure = nodeType.prototype.onConfigure;
    nodeType.prototype.onConfigure = function () {
      prevOnConfigure?.apply(this, arguments);
      const texts = normalizeToArray(this.properties?.text);
      if (this.properties.selectedIndex === undefined) this.properties.selectedIndex = 0;
      syncUI(this, texts);
    };
  },
});

동작 흐름 요약

  1. onExecuted/onConfigure에서 텍스트 배열을 정규화.

  2. 길이에 맞춰 page 콤보를 생성/업데이트(1페이지면 제거), displaytext는 항상 유지.

  3. ensureOrderPageAboveText로 page가 displaytext보다 위에 오도록 재배열.

  4. 선택 인덱스는 properties.selectedIndex로 일관 관리, UI 라벨은 “1 / N” 문자열로 표시.

  5. 키보드/마우스 조작 시 라벨→인덱스 변환으로 동기화하며 리셋 문제 없음.

더 다듬고 싶다면 텍스트 위젯 높이, 자동 줄바꿈, 프리뷰 길이 등을 옵션으로 노출해도 좋다.

Categories:

Updated: