Files
olivv-cs-ComfyUI-FunPack/easy_gen/frontend/easy_variables.js
T
digital-garbageandClaude 8e9c0479a8 Variables: own window in Easy Gen, auto-growing value fields in both UIs
Follow-up to the previous commit, per review:
- the panel is no longer docked into Easy Gen's main screen — the "$ Variables"
  button now sits in the prompt bar (next to Upload/Gallery, not pinned to the
  header) and opens a modal window, same shell as the Gallery picker
- value fields are auto-growing textareas instead of one-line inputs, in the
  Cutting Room's Composer as well: a long phrase was impossible to edit in a
  single-line field. Height is measured from scrollHeight with the border-box
  borders added back, re-fit once after layout (scrollHeight reads 0 before it)
- fully-empty rows are dropped on persist, so an accidental "Add variable" click
  never sticks to the project (the row stays on screen until it's filled in —
  the open window keeps its own working copy for exactly this reason)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 20:18:15 +03:00

226 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Easy Gen's prompt `$name` variables — quick replacements for full phrases,
// e.g. $vid = "High quality, high fidelity realistic video, motion blur, cinematic fog".
//
// Opens as its own modal off the header's "$ Variables" button (same modal-overlay/.modal
// shell as easy_gallery.js / project_menu.js) — deliberately NOT docked into the main
// screen, which stays prompt + preview only.
//
// Storage is project.variables ([{name, value}]) — the SAME project field the Editor's
// Composer writes and the same one movie_editor/server.py already hands to the builder on
// every generate ("variables": list(target.variables or [])), so nothing backend-side is
// new: templates.resolve_variables substitutes them inside refine_v2 DEAD LAST, after
// shortcut expansion and after the transition split. That order is what makes them work
// INSIDE shortcuts (a shortcut whose replacement contains $vid still resolves) while a
// value containing a comma or a trigger word can never move a scene cut.
//
// Undefined names stay literal ($foo passes through as text) and a self-referencing chain
// is left literal rather than looping — the hint line warns about both before you burn a
// generation on it.
(function () {
const { el, clear } = window.dom;
const S = window.Store;
const toggleBtn = document.getElementById("easy-vars-btn");
const promptEl = document.getElementById("easy-prompt");
if (!toggleBtn) return;
let overlay = null;
let listEl = null;
let hintEl = null;
function vars() {
return (S.projectVariables() || []).map((v) => ({
name: String((v && v.name) || ""),
value: String((v && v.value != null) ? v.value : ""),
}));
}
// Cycle detection over the declared variables — mirrors the safe-degrade guard in
// templates.resolve_variables, but reports the loop instead of silently leaving it literal.
function detectCycles() {
const map = {};
(S.projectVariables() || []).forEach((v) => {
const k = String((v && v.name) || "").replace(/^\$+/, "").trim();
if (k) map[k] = String((v && v.value) || "");
});
const refs = (n) => (String(map[n] || "").match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || [])
.map((m) => m.slice(1)).filter((r) => r in map);
const color = {}, path = [], cycles = [];
const dfs = (n) => {
color[n] = 1; path.push(n);
for (const r of refs(n)) {
if (color[r] === 1) cycles.push(path.slice(path.indexOf(r)).concat(r));
else if (!color[r]) dfs(r);
}
path.pop(); color[n] = 2;
};
Object.keys(map).forEach((n) => { if (!color[n]) dfs(n); });
return cycles;
}
// A cycle wins over the undeclared list. Only the visible prompt is scanned — a $var used
// solely inside a shortcut's replacement text is legitimate and stays silent.
function warning() {
const cycles = detectCycles();
if (cycles.length) {
return {
bad: true,
msg: "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ")
+ " — references itself. Rework it; it won't expand.",
};
}
const declared = new Set((S.projectVariables() || [])
.map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean));
const txt = promptEl ? promptEl.value : "";
const used = new Set((txt.match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((m) => m.slice(1)));
const undeclared = [...used].filter((n) => !declared.has(n));
if (undeclared.length) {
return {
bad: false,
msg: "Referencing " + undeclared.map((n) => "$" + n).join(", ")
+ " — never declared, passed through as plain text.",
};
}
return { bad: false, msg: "" };
}
// Header button: enabled only with a project, carries the count, and goes amber on a loop
// so a broken variable is visible without opening the window.
function updateButton() {
const st = S.get();
toggleBtn.disabled = !st.project;
const n = (S.projectVariables() || []).filter((v) => String((v && v.name) || "").trim()).length;
toggleBtn.textContent = "$ Variables" + (n ? ` (${n})` : "");
toggleBtn.classList.toggle("easy-vars-warn", !!warning().bad);
}
function updateHint() {
if (!hintEl) return;
const w = warning();
hintEl.textContent = w.msg;
hintEl.classList.toggle("easy-var-warn", !!w.bad);
hintEl.style.display = w.msg ? "" : "none";
updateButton();
}
// Grow a value textarea to fit its text (measured after a reset to "auto", or it can only
// ever get taller). Deferred once on mount because scrollHeight reads 0 before layout.
function autoGrow(ta) {
const fit = () => {
ta.style.height = "auto";
// scrollHeight covers content + padding; under border-box the borders must be added
// back or every line is clipped by ~2px and the textarea scrolls anyway.
const cs = getComputedStyle(ta);
const border = cs.boxSizing === "border-box"
? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
: -(parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));
ta.style.height = (ta.scrollHeight + (border || 0)) + "px";
};
fit();
if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); }
}
// Working copy for the open window. It is the source of truth for what's on screen —
// a half-typed row (blank name AND value) is dropped by the store on persist, so
// re-reading the project after every edit would delete the row being filled in.
let rows = [];
function renderList() {
if (!listEl) return;
clear(listEl);
// Every keystroke commits to the project (debounced save); only add/remove re-renders,
// so an input never loses focus mid-word.
const commit = () => { S.setProjectVariables(rows); updateHint(); };
rows.forEach((v, i) => {
const row = el("div", "easy-var-row");
row.append(el("span", "easy-var-dollar", "$"));
const nm = el("input", "easy-var-in easy-var-name");
nm.value = v.name; nm.placeholder = "name";
nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); };
row.append(nm);
row.append(el("span", "easy-var-eq", "="));
// Values are usually long phrases, so this is a textarea that grows with its content —
// a one-line input makes editing "High quality, high fidelity realistic video, …" a
// horizontal-scrolling guessing game.
const vv = el("textarea", "easy-var-in easy-var-val");
vv.rows = 1;
vv.value = v.value; vv.placeholder = "value (may reference $other)";
vv.oninput = () => { v.value = vv.value; autoGrow(vv); commit(); };
row.append(vv);
autoGrow(vv);
const rm = el("button", "btn ghost tiny", "✕");
rm.type = "button";
rm.title = "Remove variable";
rm.onclick = () => { rows.splice(i, 1); S.setProjectVariables(rows); renderList(); updateHint(); };
row.append(rm);
listEl.append(row);
});
if (!rows.length) listEl.append(el("div", "easy-vars-empty", "No variables yet."));
const add = el("button", "btn ghost tiny easy-var-add", "+ Add variable");
add.type = "button";
add.onclick = () => { rows.push({ name: "", value: "" }); S.setProjectVariables(rows); renderList(); updateHint(); };
listEl.append(add);
}
function close() {
if (overlay) { overlay.remove(); overlay = null; }
listEl = null; hintEl = null;
updateButton();
}
function open() {
if (!S.get().project) return;
close();
overlay = el("div", "modal-overlay");
const box = el("div", "modal modal-wide");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Variables"));
const x = el("button", "btn ghost tiny", "✕");
x.onclick = close;
head.append(el("div", "modal-head-right"), x);
box.append(head);
const content = el("div", "modal-content easy-vars");
content.append(el("div", "easy-vars-hint",
"Shorthand for full phrases. Type $name in the prompt (or inside a shortcut's replacement) "
+ "and it's swapped for the text below at generation — after shortcuts expand and after the "
+ "prompt splits into scenes, so a value can never move a scene cut."));
hintEl = el("div", "easy-vars-hint easy-var-hint");
content.append(hintEl);
listEl = el("div", "easy-var-list");
content.append(listEl);
box.append(content);
overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.addEventListener("keydown", function onKey(e) {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
});
document.body.append(overlay);
rows = vars();
renderList();
updateHint();
}
toggleBtn.onclick = () => (overlay ? close() : open());
// A project switch replaces the whole variable set — rebuild the open window's rows.
let renderedProjectId = null;
S.subscribe((st) => {
const pid = st.project ? st.project.id : null;
if (pid !== renderedProjectId) {
renderedProjectId = pid;
if (overlay) { rows = vars(); renderList(); updateHint(); return; }
}
updateButton();
});
if (promptEl) promptEl.addEventListener("input", () => { if (overlay) updateHint(); else updateButton(); });
updateButton();
window.EasyVariables = { open, close };
})();