Files
ausboss-ComfyUI-AusBoss/js/shared/settings_menu.mjs
T
ausboss 20afbf2536 Stitch Inpaint: move Seam into a gear menu
The card keeps its old look and height. Seam has no row and no socket
now: a small gear in the card's top-right corner opens a menu with the
two choices, each with one plain line. With blend in on, Tone match and
Fix edge halo stay in place but dim and stop taking input, with a hover
hint, and a "blend in" chip beside the gear shows the mode.

The seam widget is a socketless combo, so the frontend makes no input
slot for it in either renderer; a stale unlinked slot from an earlier
save is dropped on load. The backend input is unchanged.

The widget card gains two generic options for this: rows can be muted
by the current values, and a card can carry small corner tools that
every row's control keeps clear of. A gear-menu choice can list one
line per option.
2026-09-29 00:19:01 -05:00

516 lines
21 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.
// The gear menu: a floating settings popup shared by AusBoss panel nodes.
// Values persist per scope in localStorage, so a preference set once applies
// to every instance of that node in every workflow on this browser.
//
// const values = loadSettings("lora_loader", SCHEMA);
// button.onclick = () =>
// openSettingsMenu({ scope: "lora_loader", schema: SCHEMA, anchor: rect,
// title: "LoRA Loader settings",
// onChange: (values, key) => rerender(values) });
import { makeScrubInput } from "./scrub_input.mjs";
import { BRAND } from "./index.mjs";
import {
isOverrideActive,
isOverrideEntry,
mergeSettings,
overrideEnableValue,
overrideIsCustom,
schemaDefaults,
} from "./node_settings.mjs";
const CSS_ID = "ausboss-settings-css";
function storageKey(scope) {
return `ausboss.settings.${scope}`;
}
function readStored(scope) {
try {
const raw = window.localStorage?.getItem(storageKey(scope));
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (_error) {
return {};
}
}
function writeStored(scope, values) {
try {
window.localStorage?.setItem(storageKey(scope), JSON.stringify(values));
} catch (_error) {
// Storage full or blocked: the menu still works for this session.
}
}
export function loadSettings(scope, schema) {
return mergeSettings(schema, readStored(scope));
}
export function saveSetting(scope, schema, key, value, { persist = true } = {}) {
const stored = readStored(scope);
stored[key] = value;
// persist:false coerces and applies the value without recording it as the
// default new nodes start from - for entries that mirror per-node state.
if (persist) writeStored(scope, stored);
return mergeSettings(schema, stored);
}
export function resetSettings(scope) {
try {
window.localStorage?.removeItem(storageKey(scope));
} catch (_error) {
// Ignore: defaults apply either way.
}
}
// A small hand-drawn gear so panels get a crisp icon without any asset file.
export function gearIconSvg(size = 13) {
const teeth = [];
for (let i = 0; i < 8; i += 1) {
teeth.push(`<rect x="-1.4" y="-8" width="2.8" height="4.4" rx="1" transform="rotate(${i * 45})"/>`);
}
return (
`<svg width="${size}" height="${size}" viewBox="-8 -8 16 16" ` +
`fill="currentColor" aria-hidden="true"><g>${teeth.join("")}</g>` +
`<circle r="4.6"/><circle r="2.1" fill="#1c1f23"/></svg>`
);
}
function ensureSettingsCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-set-pop { position: fixed; z-index: 10000; width: 300px; background: #1c1f23;
border: 1px solid #3a4047; border-radius: 7px; box-shadow: 0 8px 28px rgba(0,0,0,.5);
font: 12px system-ui; color: #d7dde2; display: flex; flex-direction: column; }
.ausboss-set-pop * { box-sizing: border-box; }
.ausboss-set-head { display: flex; align-items: center; gap: 7px; padding: 9px 10px;
border-bottom: 1px solid #2c3238; color: ${BRAND}; font-weight: 600; }
.ausboss-set-head svg { flex: none; }
.ausboss-set-close { margin-left: auto; width: 22px; height: 22px; border: none;
border-radius: 5px; background: transparent; color: #9ba2aa; cursor: pointer;
font-size: 14px; line-height: 1; }
.ausboss-set-close:hover { background: #2c3238; color: #fff; }
.ausboss-set-body { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px 10px;
max-height: 60vh; overflow-y: auto; }
/* Rows keep their full height and the body scrolls: a shrinking row in a
short window lets its wrapped hint spill over the next row. */
.ausboss-set-body > * { flex-shrink: 0; }
.ausboss-set-section { padding: 10px 0 3px; color: #9ba2aa; font-size: 10px;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.ausboss-set-row { display: flex; align-items: center; gap: 10px; min-height: 32px;
padding: 2px 0; }
.ausboss-set-label { flex: 1 1 auto; min-width: 0; }
.ausboss-set-label .hint { display: block; color: #9ba2aa; font-size: 10.5px;
line-height: 1.35; margin-top: 1px; }
.ausboss-set-toggle { width: 30px; height: 16px; border-radius: 8px; border: none;
background: #3a4047; cursor: pointer; position: relative; flex: none; padding: 0; }
.ausboss-set-toggle::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%; background: #9ba2aa; transition: left .12s; }
.ausboss-set-toggle.on { background: ${BRAND}; }
.ausboss-set-toggle.on::after { left: 16px; background: #fff; }
.ausboss-set-input { width: 72px; height: 24px; border: 1px solid #3a4047; border-radius: 5px;
background: #23272c; color: inherit; text-align: center; outline: none; flex: none; }
.ausboss-set-input.wide { width: 110px; text-align: left; padding: 0 8px; }
.ausboss-set-input:focus { border-color: ${BRAND}; }
.ausboss-set-seg { display: flex; flex: none; border: 1px solid #3a4047; border-radius: 5px;
overflow: hidden; }
.ausboss-set-seg button { height: 24px; border: none; background: #23272c; color: #9ba2aa;
cursor: pointer; padding: 0 9px; font: inherit; }
.ausboss-set-seg button + button { border-left: 1px solid #3a4047; }
.ausboss-set-seg button.on { background: ${BRAND}; color: #08211f; font-weight: 600; }
.ausboss-set-foot { display: flex; align-items: center; gap: 8px; padding: 9px 10px;
border-top: 1px solid #2c3238; }
.ausboss-set-gearrow { display: flex; align-items: center; justify-content: flex-end;
width: 100%; height: 24px; padding: 0 2px; box-sizing: border-box; }
.ausboss-set-gearbtn { width: 24px; height: 24px; border: 1px solid #3a4047;
border-radius: 5px; background: #23272c; color: #9ba2aa; cursor: pointer;
display: grid; place-items: center; flex: none; padding: 0; }
.ausboss-set-gearbtn:hover { border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-set-text { color: #b9c2c9; font-size: 12px; line-height: 1.45;
padding: 4px 0 2px; }
.ausboss-set-dl { display: flex; flex-direction: column; gap: 5px; padding: 2px 0 4px; }
.ausboss-set-dl .term { color: ${BRAND}; font-weight: 600; }
.ausboss-set-dl .detail { color: #9ba2aa; line-height: 1.4; }
.ausboss-set-btn { height: 24px; border: 1px solid #3a4047; border-radius: 5px;
background: #23272c; color: #d7dde2; cursor: pointer; padding: 0 12px; font: inherit; }
.ausboss-set-btn:hover { border-color: ${BRAND}; }
.ausboss-set-btn.primary { margin-left: auto; border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-set-btn.primary:hover { background: ${BRAND}; color: #08211f; }
.ausboss-set-check { flex: none; width: 14px; height: 14px; margin: 0; accent-color: ${BRAND};
cursor: pointer; }
.ausboss-set-labeltop { display: flex; align-items: center; gap: 7px; }
.ausboss-set-row.off .ausboss-set-label { color: #7e868e; }
.ausboss-set-row.off .ausboss-set-label .hint { color: #6a727a; }
.ausboss-set-ctl { display: flex; align-items: center; gap: 6px; flex: none; }
.ausboss-set-ctl.off { opacity: .38; }
.ausboss-set-slider { flex: none; width: 76px; height: 14px; accent-color: ${BRAND};
cursor: pointer; margin: 0; }
.ausboss-set-ctl.off .ausboss-set-slider, .ausboss-set-ctl.off .ausboss-set-input {
pointer-events: none; }
.ausboss-set-trash { flex: none; width: 20px; height: 20px; border: none; border-radius: 4px;
background: transparent; color: #9ba2aa; cursor: pointer; padding: 0; display: grid;
place-items: center; }
.ausboss-set-trash:hover { background: #2c3238; color: #ff8a80; }
.ausboss-set-trash.hidden { visibility: hidden; pointer-events: none; }
.ausboss-set-moddot { flex: none; width: 5px; height: 5px; border-radius: 50%;
background: ${BRAND}; }
.ausboss-set-moddot.hidden { visibility: hidden; }
`;
document.head.append(style);
}
function el(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
// One popup at a time, every listener on one AbortController — the same
// discipline the LoRA picker popups use, so teardown cannot leak.
let activeMenu = null;
export function closeSettingsMenu() {
if (!activeMenu) return;
activeMenu.abort.abort();
activeMenu.element.remove();
activeMenu = null;
}
function buildControl(entry, values, commit) {
if (entry.type === "action") {
// A one-shot command living among the settings: no value, no storage,
// just a button. { key, label, hint, type: "action", button, onAction }.
const button = el("button", "ausboss-set-btn", entry.button ?? "Run");
button.type = "button";
button.addEventListener("click", () => entry.onAction?.());
return button;
}
if (entry.type === "toggle") {
const toggle = el("button", `ausboss-set-toggle${values[entry.key] ? " on" : ""}`);
toggle.type = "button";
toggle.addEventListener("click", () => {
const next = !toggle.classList.contains("on");
toggle.classList.toggle("on", next);
commit(entry, next);
});
return toggle;
}
if (entry.type === "choice") {
const seg = el("div", "ausboss-set-seg");
for (const option of entry.options ?? []) {
const button = el("button", values[entry.key] === option ? "on" : "", option);
button.type = "button";
button.addEventListener("click", () => {
for (const sibling of seg.children) sibling.classList.remove("on");
button.classList.add("on");
commit(entry, option);
});
seg.append(button);
}
return seg;
}
if (entry.type === "number" && entry.scrub) {
// Whole numbers unless the entry asks for decimals (a strength).
const control = makeScrubInput({
value: values[entry.key], min: entry.min, max: entry.max,
step: entry.step ?? 1, fineStep: entry.fineStep ?? entry.step ?? 1, decimals: entry.decimals ?? 0,
title: entry.hint ?? entry.label,
onChange: (value) => commit(entry, value),
});
return control.root;
}
const input = el("input", `ausboss-set-input${entry.type === "text" ? " wide" : ""}`);
input.type = "text";
if (entry.type === "number") input.inputMode = "decimal";
if (entry.placeholder) input.placeholder = entry.placeholder;
input.value = String(values[entry.key]);
const commitInput = () => {
const coerced = commit(entry, input.value);
input.value = String(coerced);
};
input.addEventListener("change", commitInput);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); commitInput(); }
event.stopPropagation();
});
if (!entry.slider) return input;
// Slider + box drive one value: the range is the coarse gesture, the box is
// the exact one, so neither is authoritative and both mirror each other.
const wrap = el("div", "ausboss-set-ctl");
const slider = el("input", "ausboss-set-slider");
slider.type = "range";
slider.min = String(entry.min ?? 0);
slider.max = String(entry.max ?? 1);
slider.step = String(entry.step ?? 0.01);
slider.value = String(values[entry.key]);
slider.addEventListener("input", () => {
const coerced = commit(entry, slider.value);
input.value = String(coerced);
});
input.addEventListener("change", () => { slider.value = input.value; });
wrap.append(slider, input);
return wrap;
}
function trashIconSvg() {
return (
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" ' +
'stroke-width="1.5" stroke-linecap="round" aria-hidden="true">' +
'<path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.2h5.8l.6-8.2M6.8 7v3.4M9.2 7v3.4"/></svg>'
);
}
// A right-aligned gear row for panels that have no other chrome to hang the
// gear on. Returns the row element; wire the click yourself.
export function makeGearRow(titleText) {
ensureSettingsCss();
const row = el("div", "ausboss-set-gearrow");
const button = el("button", "ausboss-set-gearbtn");
button.type = "button";
button.title = titleText ?? "Settings";
button.innerHTML = gearIconSvg();
row.append(button);
return { row, button };
}
// A read-only help card in the same popup slot as the settings menu — the
// title-bar "?" badge opens one. `sections` mixes prose and term lists:
// { text: "..." } | { heading: "Inputs", items: [{ term, detail }] }
export function openInfoCard({ anchor, title, sections }) {
ensureSettingsCss();
closeSettingsMenu();
const pop = el("div", "ausboss-set-pop");
const head = el("div", "ausboss-set-head");
const close = el("button", "ausboss-set-close", "×");
close.type = "button";
close.addEventListener("click", () => closeSettingsMenu());
head.append(el("span", "", title ?? "About this node"), close);
const body = el("div", "ausboss-set-body");
for (const section of sections ?? []) {
if (section.text) body.append(el("div", "ausboss-set-text", section.text));
if (!section.items?.length) continue;
if (section.heading) body.append(el("div", "ausboss-set-section", section.heading));
const list = el("div", "ausboss-set-dl");
for (const item of section.items) {
const row = el("div");
row.append(el("span", "term", item.term));
if (item.detail) row.append(el("span", "detail", ` — ${item.detail}`));
list.append(row);
}
body.append(list);
}
pop.append(head, body);
document.body.append(pop);
const abort = new AbortController();
const place = () => {
const rect = pop.getBoundingClientRect();
let left = Math.min(anchor.left, window.innerWidth - rect.width - 8);
let top = anchor.bottom + 4;
if (top + rect.height > window.innerHeight - 8) {
top = Math.max(8, anchor.top - rect.height - 4);
}
pop.style.left = `${Math.max(8, left)}px`;
pop.style.top = `${top}px`;
};
place();
requestAnimationFrame(place);
document.addEventListener(
"pointerdown",
(event) => { if (!pop.contains(event.target)) closeSettingsMenu(); },
{ capture: true, signal: abort.signal },
);
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape") { event.stopPropagation(); closeSettingsMenu(); }
},
{ capture: true, signal: abort.signal },
);
activeMenu = { element: pop, abort };
return { close: closeSettingsMenu };
}
// `initial` (when given) seeds the menu from the caller's live values — a
// node's own widgets — instead of the stored defaults; edits still persist
// to storage so future nodes start there.
export function openSettingsMenu({ scope, schema, anchor, title, onChange, initial }) {
ensureSettingsCss();
closeSettingsMenu();
let values = initial
? mergeSettings(schema, initial)
: loadSettings(scope, schema);
const pop = el("div", "ausboss-set-pop");
const head = el("div", "ausboss-set-head");
const icon = el("span");
icon.innerHTML = gearIconSvg();
const close = el("button", "ausboss-set-close", "×");
close.type = "button";
close.addEventListener("click", () => closeSettingsMenu());
head.append(icon, el("span", "", title ?? "Settings"), close);
const body = el("div", "ausboss-set-body");
// What each override held before it was switched off, so ticking it back on
// returns the user's own value rather than a canned one.
const lastActive = {};
const commit = (entry, raw) => {
// Saving also records the value as the default new nodes start from
// (unless the entry opts out with persist: false), but the menu is
// showing THIS node: merge back only the key that changed. Taking
// saveSetting's whole return would rebuild every other row from
// storage and silently revert values the open node actually holds.
const saved = saveSetting(scope, schema, entry.key, raw,
{ persist: entry.persist !== false });
values = { ...values, [entry.key]: saved[entry.key] };
onChange?.(values, entry.key);
return values[entry.key];
};
const renderRows = () => {
body.textContent = "";
for (const entry of schema) {
if (entry.key === undefined) {
body.append(el("div", "ausboss-set-section", entry.section));
continue;
}
const row = el("div", "ausboss-set-row");
if (!isOverrideEntry(entry)) {
const label = el("div", "ausboss-set-label", entry.label);
if (entry.hint) label.append(el("span", "hint", entry.hint));
row.append(label, buildControl(entry, values, commit));
body.append(row);
// A choice can say what each option does, one plain line apiece:
// { ..., type: "choice", optionHints: { option: "what it does" } }.
if (entry.optionHints) {
const list = el("div", "ausboss-set-dl");
for (const option of entry.options ?? []) {
if (!entry.optionHints[option]) continue;
const line = el("div");
line.append(el("span", "term", option), el("span", "detail", ` — ${entry.optionHints[option]}`));
list.append(line);
}
body.append(list);
}
continue;
}
// Override row: an explicit on/off box, so "off" reads as off instead
// of relying on the user knowing the neutral value by heart.
const check = el("input", "ausboss-set-check");
check.type = "checkbox";
check.title = `Send ${entry.label} with the request; off leaves it to the server`;
const dot = el("span", "ausboss-set-moddot hidden");
dot.title = "Changed from the suggested value";
const trash = el("button", "ausboss-set-trash hidden");
trash.type = "button";
trash.innerHTML = trashIconSvg();
trash.title = `Reset ${entry.label} to ${overrideEnableValue(entry, undefined)}`;
const syncRow = () => {
const on = isOverrideActive(entry, values[entry.key]);
const custom = overrideIsCustom(entry, values[entry.key]);
check.checked = on;
row.classList.toggle("off", !on);
wrap.classList.toggle("off", !on);
dot.classList.toggle("hidden", !custom);
trash.classList.toggle("hidden", !custom);
};
// Typing the neutral value by hand must uncheck the box, so the row
// re-syncs after every edit the control itself makes.
const control = buildControl(entry, values, (rowEntry, raw) => {
const out = commit(rowEntry, raw);
syncRow();
return out;
});
const wrap = control.classList.contains("ausboss-set-ctl")
? control
: el("div", "ausboss-set-ctl");
if (wrap !== control) wrap.append(control);
check.addEventListener("change", () => {
if (!check.checked) lastActive[entry.key] = values[entry.key];
commit(entry, check.checked
? overrideEnableValue(entry, lastActive[entry.key])
: entry.neutral);
renderRows();
});
trash.addEventListener("click", () => {
commit(entry, overrideEnableValue(entry, undefined));
renderRows();
});
const labelBox = el("div", "ausboss-set-label");
const labelTop = el("div", "ausboss-set-labeltop");
labelTop.append(check, el("span", "", entry.label));
labelBox.append(labelTop);
if (entry.hint) labelBox.append(el("span", "hint", entry.hint));
row.append(labelBox, dot, trash, wrap);
syncRow();
body.append(row);
}
};
renderRows();
const foot = el("div", "ausboss-set-foot");
const reset = el("button", "ausboss-set-btn", "Reset");
reset.type = "button";
reset.title = "Back to defaults for this node type";
reset.addEventListener("click", () => {
resetSettings(scope);
// persist:false entries mirror the open node, not a stored default, so
// a reset leaves them as they are: resetting the LoRA Loader's gear must
// not fold a node's separate CLIP strengths into its model strengths.
const kept = {};
for (const entry of schema) {
if (entry.key !== undefined && entry.persist === false) kept[entry.key] = values[entry.key];
}
values = { ...schemaDefaults(schema), ...kept };
renderRows();
onChange?.(values, null);
});
const done = el("button", "ausboss-set-btn primary", "Done");
done.type = "button";
done.addEventListener("click", () => closeSettingsMenu());
foot.append(reset, done);
pop.append(head, body, foot);
document.body.append(pop);
const abort = new AbortController();
const place = () => {
const rect = pop.getBoundingClientRect();
let left = Math.min(anchor.left, window.innerWidth - rect.width - 8);
let top = anchor.bottom + 4;
if (top + rect.height > window.innerHeight - 8) {
top = Math.max(8, anchor.top - rect.height - 4);
}
pop.style.left = `${Math.max(8, left)}px`;
pop.style.top = `${top}px`;
};
place();
requestAnimationFrame(place);
document.addEventListener(
"pointerdown",
(event) => { if (!pop.contains(event.target)) closeSettingsMenu(); },
{ capture: true, signal: abort.signal },
);
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape") { event.stopPropagation(); closeSettingsMenu(); }
},
{ capture: true, signal: abort.signal },
);
activeMenu = { element: pop, abort };
return { close: closeSettingsMenu };
}