493 lines
20 KiB
JavaScript
493 lines
20 KiB
JavaScript
// 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; }
|
||
.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) {
|
||
const control = makeScrubInput({
|
||
value: values[entry.key], min: entry.min, max: entry.max,
|
||
step: entry.step ?? 1, fineStep: entry.step ?? 1, 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);
|
||
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);
|
||
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 };
|
||
}
|