Files
ausboss-ComfyUI-AusBoss/js/shared/settings_menu.mjs
ausboss be8b071c55 Ctrl + Enter runs the workflow from the boxes in our popups
2.6.2 let app shortcuts through from the boxes on a node face. Six more
boxes still stopped every key: the LoRA picker's search box, the trigger
word and template name boxes, the gear menu's boxes, the picture and video
picker's filter, and the server file path box on the video nodes. They use
keepKeyInField now, and a search box no longer treats Ctrl + Enter as a pick.

A test reads every keydown handler under js/ and fails on one that stops a
key before looking at it. The Workflow Note editor and the keep or discard
question are the two that own the keyboard on purpose.
2026-10-05 09:35:15 -05:00

520 lines
21 KiB
JavaScript
Raw Permalink 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 { isAppShortcut, keepKeyInField } from "./canvas_passthrough.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) => {
keepKeyInField(event);
if (event.key !== "Enter") return;
// Ctrl + Enter keeps the typed value and goes on to ComfyUI to run.
if (!isAppShortcut(event)) event.preventDefault();
commitInput();
});
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 };
}