Files
ausboss-ComfyUI-AusBoss/js/shared/settings_menu.mjs
T
ausboss ae931c9b36 Add shared per-node settings and help-card infrastructure
A schema-driven gear menu (toggles, numbers, text, segmented choices)
persisted per node type in localStorage, a reusable info-card popup, a
widget show/hide helper that keeps widgets_values stable, and a hand-drawn
gear icon - the plumbing the LoRA Loader and LM Studio gears build on.
2026-08-16 19:42:08 -05:00

354 lines
14 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 { BRAND } from "./index.mjs";
import { mergeSettings, 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) {
const stored = readStored(scope);
stored[key] = value;
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; }
.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; }
`;
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 === "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;
}
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();
});
return input;
}
// 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");
const commit = (entry, raw) => {
values = saveSetting(scope, schema, entry.key, raw);
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");
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);
}
};
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);
values = schemaDefaults(schema);
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 };
}