ComfyUI Debug 노드 리팩터링
이거 내가 개발한 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으로 되돌아가는 버그”까지 잡은 버전을 아래에 제시할게. 핵심 아이디어는 다음이야.
-
선택 인덱스를 this.properties.selectedIndex로 보존해서 재실행(onExecuted)·재구성(onConfigure) 시에 값이 덮어써지지 않게 함.
-
콤보 위젯은 “필요할 때만” 만들고(길이 ≥ 2), 불필요하면 제거. 제거 시에 안전하게 widgets 배열에서 뺀 뒤 노드 크기 재계산.
-
onChange 안에서 comboWidget.value를 다시 건드리지 않음. 사용자가 키보드/마우스로 바꾼 값을 그대로 쓰고, 그 값을 properties에 저장만 함.
-
옵션 길이가 바뀌어도 현재 selectedIndex를 최대 길이-1로만 클램프하고, 굳이 0으로 초기화하지 않음.
-
입력이 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);
};
},
});
버그 원인과 해결 포인트 설명
-
값 리셋 요인 제거
기존 코드에서는 콤보 생성 직후 comboWidget.value = 0 같은 강제 초기화가 있었고, addWidget 콜백에서 comboWidget.value를 다시 세팅하는 로직도 섞여 있어서 사용자의 키보드 변경 직후 여러 경로로 값이 덮어써질 여지가 있었어. 새 코드는 사용자 입력 흐름을 존중해 “onChange에서 properties와 텍스트만 동기화”하고, 콤보 value를 임의로 다시 쓰지 않아 키보드(화살표) 조작 시 0으로 리셋되지 않아. -
상태의 단일 소스
선택 인덱스를 this.properties.selectedIndex로 통일해서 onExecuted, onConfigure 사이에서 일관성 있게 유지돼. 데이터 길이가 변할 때도 selectedIndex를 최대 길이-1로만 클램프하니 사용자가 고른 페이지가 가능한 한 유지돼. -
콤보 동적 생성/제거
texts.length ≤ 1이면 콤보를 제거하고, ≥ 2이면 필요 시에만 생성해. 길이가 1→여러 개, 여러 개→1, 혹은 N이 바뀌는 경우에도 syncComboWidget이 알아서 옵션/선택/제거를 처리해. -
위젯 제거 안전성
removeWidget 유틸로 widgets 배열에서 안전하게 빼고, computeSize를 호출해서 노드 레이아웃이 깨지지 않도록 했어.
추가로 고려한 엣지 케이스
-
text가 아예 오지 않거나(null/undefined) 빈 리스트일 때에도 빈 배열로 처리하고 텍스트 위젯엔 빈 문자열 표시.
-
콤보 옵션이 갱신될 때 현재 선택 인덱스를 유지 가능한 범위에서 유지(클램프만 수행).
-
직렬화 방지: 표시용 위젯들은 serialize를 false로 둬서 프롬프트에 새는 걸 막음.
파이썬 쪽은 건드릴 필요 없어 보이지만, 혹시 텍스트가 항상 리스트가 되길 원한다면 notify에서 ui 쪽으로 항상 list를 주는 지금 구현이 이미 적절해. IS_CHANGED에서 리스트면 튜플로 바꿔주는 것도 캐시 무효화에 도움이 되니 그대로 두면 돼.
다음 단계로 이어가기
-
콤보 대신 좌우 화살표 버튼(두 개의 push 버튼 위젯)을 원하는 경우에도 같은 selectedIndex를 써서 쉽게 확장 가능해.
-
텍스트가 매우 길다면 표시용 위젯 높이/자동 줄바꿈을 추가로 조절할 수 있어(style.whiteSpace, style.height 등).
Array 원소가 숫자로 되어있으니 잘 동작을 안 하는 것 같아. 문자열로 바꿔줘
좋아. 콤보 옵션과 value를 전부 문자열로 바꾸고, 내부 상태는 숫자로 유지하도록 정리했어. 핵심은 다음 두 가지야.
-
UI 쪽(콤보)은 "라벨 문자열"을 사용한다. 예: "0", "1", "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)일 땐 콤보를 만들지 않는 기존 동작도 그대로 유지돼.
핵 포인트
-
widgets 배열에서 page가 displaytext보다 항상 앞에 오도록 재배치.
-
콤보 생성/제거, 길이 변화, 화살표 조작, 1페이지 시 제거 로직은 이전 버전과 동일.
-
구버전(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);
};
},
});
동작 흐름 요약
-
onExecuted/onConfigure에서 텍스트 배열을 정규화.
-
길이에 맞춰 page 콤보를 생성/업데이트(1페이지면 제거), displaytext는 항상 유지.
-
ensureOrderPageAboveText로 page가 displaytext보다 위에 오도록 재배열.
-
선택 인덱스는 properties.selectedIndex로 일관 관리, UI 라벨은 “1 / N” 문자열로 표시.
-
키보드/마우스 조작 시 라벨→인덱스 변환으로 동기화하며 리셋 문제 없음.
더 다듬고 싶다면 텍스트 위젯 높이, 자동 줄바꿈, 프리뷰 길이 등을 옵션으로 노출해도 좋다.