fix: Composer variables — own scroll area, one-line rows, filter; add/remove no longer jump to top

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-23 23:51:06 +03:00
co-authored by Claude Opus 5.5
parent a44074fe52
commit a37ce9caeb
2 changed files with 66 additions and 21 deletions
+56 -21
View File
@@ -219,6 +219,7 @@
// Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline.
wrap.append(composeVariables());
wrap.classList.toggle("vars-open", varsOpen);
// Segmented detailing targets — only while the Chain Sampler feature is enabled
// (Engine ▸ Chain Sampler ▸ Experimental), so prompt-craft and "what gets detailed"
// live side by side without adding a permanently-visible field.
@@ -364,50 +365,84 @@
if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); }
}
let varFilter = ""; // Variables filter text (kept across repaints)
function composeVariables() {
const box = el("div", "compose-vars");
const toggle = el("button", "btn ghost tiny composer-var-toggle",
(varsOpen ? "▾ " : "▸ ") + "Variables");
// Local working copy; committed (persisted) on every edit. Add/remove edit the list in
// place — a full repaint would throw the user back to the top of the Composer.
const vars = (S.projectVariables() || []).map((v) => ({
name: String((v && v.name) || ""), value: String((v && v.value != null) ? v.value : ""),
}));
const commit = () => S.setProjectVariables(vars);
const head = el("div", "compose-var-head");
const toggle = el("button", "btn ghost tiny composer-var-toggle");
const label = () => (varsOpen ? "▾ " : "▸ ") + "Variables" + (vars.length ? ` (${vars.length})` : "");
toggle.textContent = label();
toggle.title = "Project $name variables — substituted into the prompt at generation";
toggle.onclick = () => { varsOpen = !varsOpen; render(); };
box.append(toggle);
head.append(toggle);
box.append(head);
varHintEl = el("div", "insp-hint compose-var-hint");
box.append(varHintEl);
if (varsOpen) {
const filter = el("input", "lib-in compose-var-filter");
filter.type = "search"; filter.placeholder = "Filter"; filter.value = varFilter;
const add = el("button", "btn ghost tiny", "+ Add");
add.title = "Add a variable";
head.append(filter, add);
const list = el("div", "compose-var-list");
// Local working copy; committed (persisted) on every edit, re-rendered only on add/remove.
const vars = (S.projectVariables() || []).map((v) => ({
name: String((v && v.name) || ""), value: String((v && v.value != null) ? v.value : ""),
}));
const commit = () => S.setProjectVariables(vars);
vars.forEach((v, i) => {
const applyFilter = () => {
const q = varFilter.trim().toLowerCase();
list.querySelectorAll(".compose-var-row").forEach((row) => {
row.style.display = !q || row._var.name.toLowerCase().includes(q)
|| row._var.value.toLowerCase().includes(q) ? "" : "none";
});
};
filter.oninput = () => { varFilter = filter.value; applyFilter(); };
const mkRow = (v) => {
const row = el("div", "compose-var-row");
row._var = v;
row.append(el("span", "compose-var-dollar", "$"));
const nm = el("input", "lib-in compose-var-name");
nm.value = v.name; nm.placeholder = "name";
nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); updateVarHint(); };
row.append(nm);
row.append(el("span", "compose-var-eq", "="));
// Values are usually long phrases, so this grows with its content — a one-line input
// makes editing "High quality, high fidelity realistic video, …" a horizontal-
// scrolling guessing game.
// One line at rest so a long list stays scannable; grows to the full text while
// being edited.
const vv = el("textarea", "lib-in compose-var-val");
vv.rows = 1;
vv.value = v.value; vv.placeholder = "value (may reference $other)";
vv.oninput = () => { v.value = vv.value; autoGrowVar(vv); commit(); updateVarHint(); };
vv.value = v.value; vv.placeholder = "value (may reference $other)"; vv.title = v.value;
vv.oninput = () => { v.value = vv.value; vv.title = vv.value; autoGrowVar(vv); commit(); updateVarHint(); };
vv.onfocus = () => autoGrowVar(vv);
vv.onblur = () => { vv.style.height = ""; vv.scrollTop = 0; };
row.append(vv);
autoGrowVar(vv);
const rm = el("button", "btn ghost tiny", "✕");
rm.title = "Remove variable";
rm.onclick = () => { vars.splice(i, 1); commit(); render(); };
rm.onclick = () => {
vars.splice(vars.indexOf(v), 1);
row.remove(); commit(); updateVarHint(); toggle.textContent = label();
};
row.append(rm);
list.append(row);
});
const add = el("button", "btn ghost tiny", "+ Add variable");
add.onclick = () => { vars.push({ name: "", value: "" }); commit(); render(); };
list.append(add);
return row;
};
vars.forEach((v) => list.append(mkRow(v)));
add.onclick = () => {
const v = { name: "", value: "" };
vars.push(v); commit();
varFilter = ""; filter.value = ""; applyFilter();
const row = mkRow(v);
list.append(row); toggle.textContent = label();
row.scrollIntoView({ block: "nearest" });
row.querySelector(".compose-var-name").focus();
};
applyFilter();
box.append(list);
}
// Defer so composeTextarea is assigned before the first scan.
+10
View File
@@ -1610,6 +1610,14 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
.compose-var-hint { margin-top: 4px; }
.compose-var-hint.compose-var-warn { color: var(--accent); }
.compose-var-list { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
.compose-var-head { display: flex; gap: 8px; align-items: center; }
.compose-var-head .compose-var-filter { flex: 1; min-width: 0; }
.compose-var-head .btn { flex: 0 0 auto; }
/* Open: prompt and list split the window, and the list scrolls on its own — the prompt
never scrolls out of sight while working through a long list. */
.compose-bin.vars-open .compose-ta { flex: 1 1 0; min-height: 120px; }
.compose-bin.vars-open .compose-vars { flex: 1 1 0; min-height: 180px; display: flex; flex-direction: column; }
.compose-bin.vars-open .compose-var-list { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
.compose-var-row { display: flex; gap: 6px; align-items: flex-start; }
.compose-var-row > .compose-var-dollar, .compose-var-row > .compose-var-eq,
.compose-var-row > .btn { margin-top: 5px; }
@@ -1618,6 +1626,8 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
.compose-var-row .compose-var-name { flex: 0 0 30%; min-width: 0; }
/* Value is an auto-growing textarea (JS sets the height) — no resize handle, no scrollbar. */
.compose-var-row .compose-var-val { flex: 1; min-width: 0; resize: none; overflow: hidden; }
/* At rest: exactly one unwrapped line, same height as the name field beside it. */
.compose-var-row .compose-var-val:not(:focus) { white-space: nowrap; height: 30px; }
.compose-var-row .btn { flex: 0 0 auto; }
/* config validation banner */