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:
co-authored by
Claude Opus 5.5
parent
a44074fe52
commit
a37ce9caeb
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user