From a37ce9caeb4c9ed582c99cbf7cbf633e8f5577bb Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:51:06 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20Composer=20variables=20=E2=80=94=20own?= =?UTF-8?q?=20scroll=20area,=20one-line=20rows,=20filter;=20add/remove=20n?= =?UTF-8?q?o=20longer=20jump=20to=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- movie_editor/frontend/composer.js | 77 ++++++++++++++++++++++--------- movie_editor/frontend/styles.css | 10 ++++ 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js index d3e2f189..eb76860f 100644 --- a/movie_editor/frontend/composer.js +++ b/movie_editor/frontend/composer.js @@ -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. diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 85a1e9c6..3d247688 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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 */