feat: compact + Negative, equal split when present, auto-grow prompts (#264)

Prompt editors now give positive the full width when no negative is set,
with a compact '+ Negative' control; adding a negative splits the row
50/50. Flake and preset prompt fields auto-grow to fit their content (new
attachAutoGrow helper, capped with scroll). Variant-choice editors get the
narrow button + equal split while keeping their image-matched height.
This commit is contained in:
Mehdi Chaid
2026-06-02 23:27:13 -04:00
parent 9eb9789b24
commit 301dd3a2c4
3 changed files with 58 additions and 17 deletions
+27 -10
View File
@@ -3,7 +3,7 @@ import {
css, makeButton, makeSmallButton, makeComfyLabel, makeComfyInput,
makeComfyDropdown, makePanelDropdown, makeSearchableDropdown,
makeComfyNumberInput, makeComfyValueSlider, makeSmallValueSlider,
makeTextarea, makeLabel, makeNumberInput,
makeTextarea, makeLabel, makeNumberInput, attachAutoGrow,
familyFolder, makeHoverRemoveWrapper, CN_MODEL_MAP,
} from "./utils.js";
import {
@@ -801,22 +801,32 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
fieldWrap.appendChild(promptBox);
const posCol = document.createElement("div");
css(posCol, "flex:0 0 70%;display:flex;flex-direction:column;gap:4px;min-width:0;");
css(posCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
promptBox.appendChild(posCol);
const negCol = document.createElement("div");
css(negCol, "flex:0 0 30%;display:flex;flex-direction:column;gap:4px;min-width:0;");
css(negCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
promptBox.appendChild(negCol);
const taCss = "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;";
function renderPrompts() {
posCol.replaceChildren();
negCol.replaceChildren();
const hasNeg = fieldState.prompt?.negative != null;
// Positive spans the width when alone; equal split with a
// negative present. The "+ Negative" placeholder stays
// compact so it never steals the positive's space (#264).
posCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
negCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
if (fieldState.prompt?.positive != null) {
const posTA = makeTextarea(fieldState.prompt.positive, "positive prompt", 3);
css(posTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;");
css(posTA, taCss);
posTA.addEventListener("change", () => { fieldState.prompt.positive = posTA.value; });
posTA.addEventListener("input", () => { fieldState.prompt.positive = posTA.value; });
attachAutoGrow(posTA);
const posWrap = makeHoverRemoveWrapper(posTA, () => {
if (fieldState.prompt.negative == null) {
fieldState.prompt = null;
@@ -842,11 +852,12 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
posCol.appendChild(posBtn);
}
if (fieldState.prompt?.negative != null) {
if (hasNeg) {
const negTA = makeTextarea(fieldState.prompt.negative, "negative prompt", 2);
css(negTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;");
css(negTA, taCss);
negTA.addEventListener("change", () => { fieldState.prompt.negative = negTA.value; });
negTA.addEventListener("input", () => { fieldState.prompt.negative = negTA.value; });
attachAutoGrow(negTA);
const negWrap = makeHoverRemoveWrapper(negTA, () => {
if (fieldState.prompt.positive == null) {
fieldState.prompt = null;
@@ -861,7 +872,7 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
} else {
const negBtn = document.createElement("button");
negBtn.textContent = "+ Negative";
css(negBtn, "flex:1;min-height:60px;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;display:flex;align-items:center;justify-content:center;gap:3px;user-select:none;box-sizing:border-box;white-space:nowrap;padding:0 6px;");
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;box-sizing:border-box;white-space:nowrap;padding:6px 10px;");
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
negBtn.addEventListener("click", () => {
@@ -1266,11 +1277,11 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
leftCol.appendChild(promptsWrap);
const choicePosCol = document.createElement("div");
css(choicePosCol, "flex:0 0 70%;display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
css(choicePosCol, "display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
promptsWrap.appendChild(choicePosCol);
const choiceNegCol = document.createElement("div");
css(choiceNegCol, "flex:0 0 30%;display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
css(choiceNegCol, "display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
promptsWrap.appendChild(choiceNegCol);
const rightCol = document.createElement("div");
@@ -1406,6 +1417,12 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
choicePosCol.replaceChildren();
choiceNegCol.replaceChildren();
// Positive spans the width when alone; equal
// split with a compact "+ Negative" otherwise (#264).
const hasNeg = choice.negative != null;
choicePosCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
choiceNegCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
if (choice.positive != null) {
const cPos = makeTextarea(choice.positive || "", "extra positive", 2);
// Fill column height (the column matches the image height via align-items:stretch on bodyRow).
@@ -1450,7 +1467,7 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
} else {
const negBtn = document.createElement("button");
negBtn.textContent = "+ Negative";
css(negBtn, "flex:1;min-height:50px;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;display:flex;align-items:center;justify-content:center;gap:3px;user-select:none;box-sizing:border-box;white-space:nowrap;padding:0 6px;");
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;box-sizing:border-box;white-space:nowrap;padding:6px 10px;");
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
negBtn.addEventListener("click", () => {
+14 -7
View File
@@ -2,7 +2,7 @@ import { openOverlay } from "./modal.js";
import {
css, makeButton, makeComfyLabel, makeComfyInput,
makeComfyDropdown, makeSearchableDropdown, makeComfySlider,
makeTextarea, makeHoverRemoveWrapper,
makeTextarea, makeHoverRemoveWrapper, attachAutoGrow,
} from "./utils.js";
import { fetchPreset, fetchCheckpoints, fetchVaes, fetchEmbeddings } from "./api.js";
import { openFileBrowser } from "./pickers.js";
@@ -460,19 +460,20 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
css(promptRow, "display:flex;gap:8px;align-items:stretch;");
content.appendChild(promptRow);
const posCol = document.createElement("div");
css(posCol, "flex:0 0 70%;min-width:0;display:flex;flex-direction:column;gap:4px;");
css(posCol, "min-width:0;display:flex;flex-direction:column;gap:4px;");
promptRow.appendChild(posCol);
const negCol = document.createElement("div");
css(negCol, "flex:0 0 30%;min-width:0;display:flex;flex-direction:column;gap:4px;");
css(negCol, "min-width:0;display:flex;flex-direction:column;gap:4px;");
promptRow.appendChild(negCol);
const posLabel = makeComfyLabel("Positive prompt");
posCol.appendChild(posLabel);
const posTA = makeTextarea(promptState.positive, "masterpiece, best quality", 3);
css(posTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;min-height:78px;flex-shrink:0;");
css(posTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;min-height:78px;flex-shrink:0;");
posTA.addEventListener("focus", () => posTA.style.borderColor = "#555");
posTA.addEventListener("blur", () => posTA.style.borderColor = "#333");
posTA.addEventListener("input", () => { promptState.positive = posTA.value; });
attachAutoGrow(posTA);
// Positive prompt is required, but we still wrap it so the styling is
// consistent with the negative side; no remove button needed though, so
// hover-X helper isn't applied here — only on negative.
@@ -480,13 +481,19 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
function renderNegative() {
negCol.replaceChildren();
if (promptState.negative != null) {
const hasNeg = promptState.negative != null;
// Equal split when a negative is present; positive spans the width
// and the "+ Negative" stays compact when it's absent (#264).
posCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
negCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
if (hasNeg) {
negCol.appendChild(makeComfyLabel("Negative prompt"));
const negTA = makeTextarea(promptState.negative, "worst quality, low quality", 3);
css(negTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;min-height:78px;flex-shrink:0;");
css(negTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;min-height:78px;flex-shrink:0;");
negTA.addEventListener("focus", () => negTA.style.borderColor = "#555");
negTA.addEventListener("blur", () => negTA.style.borderColor = "#333");
negTA.addEventListener("input", () => { promptState.negative = negTA.value; });
attachAutoGrow(negTA);
const negWrap = makeHoverRemoveWrapper(negTA, () => {
promptState.negative = null;
renderNegative();
@@ -498,7 +505,7 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
const negBtn = document.createElement("button");
negBtn.type = "button";
negBtn.textContent = "+ Negative";
css(negBtn, "flex:1;min-height:78px;cursor:pointer;border-radius:6px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;display:flex;align-items:center;justify-content:center;user-select:none;");
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:6px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;white-space:nowrap;padding:8px 12px;");
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
negBtn.addEventListener("click", () => {
+17
View File
@@ -302,6 +302,23 @@ export function makeTextarea(value = "", placeholder = "", rows = 3) {
return el;
}
// Grow a textarea to fit its content up to maxPx, then scroll (#264). Returns
// the resize fn so callers can trigger it after programmatic value changes.
export function attachAutoGrow(el, maxPx = 240) {
el.style.resize = "none";
el.style.overflowY = "hidden";
const resize = () => {
el.style.height = "auto";
const h = Math.min(el.scrollHeight, maxPx);
el.style.height = `${h}px`;
el.style.overflowY = el.scrollHeight > maxPx ? "auto" : "hidden";
};
el.addEventListener("input", resize);
// scrollHeight needs layout — size on the next frame once in the DOM.
requestAnimationFrame(resize);
return resize;
}
export function makeLabel(text) {
const l = document.createElement("div");
l.textContent = text;