feat: text gate protected mode — frontend toggle + hidden stored_text

Adds a '🔒 Protected (text node)' toggle. When on, the DOM editor is a free text
box whose value mirrors into the hidden stored_text widget; the node outputs that
text and ignores upstream (no pause, socket events ignored). Persists via the
protected + stored_text widgets; restored on configure. stored_text is single-line
so it hides cleanly (pool_id trick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:48:33 +02:00
parent b4639a73d3
commit d0dafa1d39
2 changed files with 76 additions and 2 deletions
+3 -1
View File
@@ -30,7 +30,9 @@ class TextGate:
"text": ("STRING", {"forceInput": True}),
"signal": (ANY, {}),
"protected": ("BOOLEAN", {"default": False}),
"stored_text": ("STRING", {"default": "", "multiline": True}),
# single-line so the frontend can fully hide it (the DOM editor
# is the real text box); the value still holds arbitrary text.
"stored_text": ("STRING", {"default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
+73 -1
View File
@@ -28,6 +28,43 @@ const MIN_EDITOR_H = 140; // textarea floor
const BTN_ROW_H = 34; // Pass button row
const MARGIN = 10; // ComfyUI DOM-widget inset, matches the other nodes
// ---- protected-mode widgets -------------------------------------------------
// `protected` (BOOLEAN toggle) + `stored_text` (hidden STRING) are real backend
// widgets. When protected, the node acts as a plain text node: it outputs
// stored_text and ignores upstream (no pause). The DOM textarea is the visible
// editor and mirrors its value into stored_text so it persists and reaches run().
function widgetByName(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function isProtected(node) {
return !!widgetByName(node, "protected")?.value;
}
// mirror the editor text into the hidden stored_text widget (persist + backend)
function syncStored(node) {
const w = widgetByName(node, "stored_text");
if (w) w.value = node._tg?.area?.value ?? "";
}
// collapse the auto-created stored_text widget out of the layout (pool_id trick)
function hideStoredWidget(node) {
const w = widgetByName(node, "stored_text");
if (w) w.computeSize = () => [0, -4];
}
// reflect the persisted protected/stored_text state into the editor + UI
function applyPersistedMode(node) {
if (!node._tg) return;
if (isProtected(node)) {
node._tg.area.value = widgetByName(node, "stored_text")?.value ?? "";
setState(node, "protected");
} else {
setState(node, "idle");
}
}
// ---- server call ------------------------------------------------------------
async function postPass(node, text) {
@@ -64,10 +101,17 @@ function setState(node, s) {
node._tgState = s;
const tg = node._tg;
if (!tg) return;
tg.pass.style.display = s === "passed" ? "none" : "";
// Pass is hidden once passed AND in protected mode (no pause there);
// Run-from-here only in the passed state.
tg.pass.style.display = (s === "passed" || s === "protected") ? "none" : "";
tg.runHere.style.display = s === "passed" ? "" : "none";
if (s === "paused") tg.status.textContent = "edit, then Pass";
else if (s === "passed") tg.status.textContent = "passed — Run from here to re-run";
else if (s === "protected") tg.status.textContent = "🔒 protected — outputs this text (upstream ignored)";
else tg.status.textContent = "";
tg.area.placeholder = s === "protected"
? "type text (used as a text node)…"
: "waiting for a run…";
node.setDirtyCanvas?.(true, true);
}
@@ -123,6 +167,8 @@ function setupTextGateNode(node) {
area.placeholder = "waiting for a run…";
// don't let typing/space toggle node selection or graph shortcuts
area.onkeydown = (e) => e.stopPropagation();
// keep the hidden stored_text widget mirrored so edits persist + reach run()
area.oninput = () => syncStored(node);
const btns = document.createElement("div");
btns.className = "tgate-btns";
@@ -173,6 +219,22 @@ function setupTextGateNode(node) {
return r;
};
// protected-mode wiring: hide the stored_text widget, label + react to the
// toggle, and reflect the persisted mode/text into the editor.
hideStoredWidget(node);
const pw = widgetByName(node, "protected");
if (pw) {
pw.label = "🔒 Protected (text node)";
const prev = pw.callback;
pw.callback = function () {
const r = prev?.apply(this, arguments);
if (isProtected(node)) { syncStored(node); setState(node, "protected"); }
else setState(node, "idle");
return r;
};
}
applyPersistedMode(node);
// sensible default size; the node stays freely resizable (no width floor lock)
node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]);
syncWidgetWidth(node);
@@ -187,6 +249,7 @@ app.registerExtension({
const d = e.detail || {};
const node = app.graph?.getNodeById?.(parseInt(d.id, 10));
if (!node || node.type !== NODE || !node._tg) return;
if (isProtected(node)) return; // protected = no pause; ignore stray events
// Sticky edit by intent: a Run-from-here re-queue (the _tgKeepEdit flag)
// keeps YOUR edited text so the gate re-emits it downstream; a normal
// Queue shows whatever the upstream produced. Keying off the button —
@@ -211,5 +274,14 @@ app.registerExtension({
setupTextGateNode(this);
return r;
};
// loaded workflows restore protected + stored_text after create — re-apply
// the mode so the editor + UI match the saved state.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
if (this._tg) applyPersistedMode(this);
return r;
};
},
});