Files
ausboss-ComfyUI-AusBoss/js/shared/widget_card.mjs
ausboss ded731859c Integer and Float: a big look, like Text (#270)
Integer and Float get the same gear as Text, with a big look: one large centred number with its arrows, in a taller row. The Pika H3 Flex workflow uses it for Seconds.

The look is kept in the node's properties (ausboss_text_look: "big"), so saved values and what the node sends don't change. A row with its own height in a look now takes that height when the look changes. Docs and changelog updated.
2026-11-03 13:45:54 -06:00

857 lines
47 KiB
JavaScript

// The widget card: one compact DOM panel that stands in for a node's classic
// canvas widgets - the full-width rounded rows with an arrow at each end.
//
// The widgets stay the single source of truth. Every row mirrors one hidden
// standard widget (or a pair of them): numbers become the pack's scrub
// control, short combos a segmented pill, long combos a select, booleans an
// off | on pill, strings a text field, a multiline string a textarea, and a hex
// string named like a color gets a swatch. Rows can depend on other values
// (`when`) and sit behind a disclosure (`group`), so a node with a dozen
// inputs shows the three that matter and keeps the rest one click away,
// with their values untouched.
//
// Save/load, undo, API format and widget-to-input links all keep working
// through the widgets: the card writes `widget.value`, runs the widget's own
// callback, and re-reads everything on configure, on connection changes and
// after any callback. A row whose widget is driven by a link dims out.
//
// Links still land on rows. The frontend draws a widget's input socket at
// the widget's own `y` and only while it matters (a link is being dragged
// or the socket is connected), so each hidden widget reports the y of the
// row standing in for it: drag a link over the card and a socket appears on
// every row that can take it, drop it on the row and the widget is driven
// by the link, exactly as it was with the classic widget. One socket per
// row, so a row never holds two linkable widgets: a pair either splits into
// rows or declares `top: true`, which moves its widgets' sockets up among
// the node's real inputs (the widget stays; its slot just stops being a
// "widget input" and is laid out with the others), where a link greys the
// field out like the classic converted input did.
//
// Pure decisions (which rows show, how tall the card is, how a number
// scrubs, where a socket sits) live in widget_card_math.mjs for node:test;
// this file is the DOM.
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
import { keepKeyInField } from "./canvas_passthrough.mjs";
import { createMediaPicker } from "./media_picker.mjs";
import { ensureNodeMinHeight, fillNodeHeight, holdNodeMinWidth, nodeHeightAfterCardChange, pinVuePanelHeight } from "./panel_layout.mjs";
import { makeScrubInput, quantizeScrubValue, stepDecimals } from "./scrub_input.mjs";
import { limitsLabel, narrowedRange } from "./wired_limits.mjs";
import { hideWidget } from "./widget_visibility.mjs";
import {
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, UNIT_SLOT_WIDTH,
cardChangeRestore, cardHeight, choiceLabel, commitWidgetValue, comboValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops, scrubSteps, socketWidgetY, visibleRows,
widgetTooltip,
} from "./widget_card_math.mjs";
export * from "./widget_card_math.mjs";
const CSS_ID = "ausboss-widget-card-css";
export const CARD_WIDGET = "ausboss_widget_card";
// The frontend's DOM-widget wrapper eats this much of the declared height
// in insets, so the widget declares content plus this and the card's rows
// get exactly the room they were measured for.
const WRAPPER_INSET = 16;
// The wrapper's top inset: where the card's element starts below the
// widget's layout y (DomWidgets: margin 10 per side).
const WRAPPER_TOP = 10;
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
// A boolean pill's state labels must fit half a control; longer text is a
// tooltip, and the pill says on / off.
const SWITCH_LABEL_MAX = 16;
// The big look of a textarea row (the Text node's gear): large centred
// letters in the Callout's teal, with its glow, in a field as wide as the card. Two lines fit the field at its
// smallest; the side padding keeps the text clear of the gear in the
// field's corner, and the same on the left keeps it centred on the card.
const BIG_LOOK = "big";
const BIG_FONT = 22;
const BIG_LINE = 26;
const BIG_PAD_Y = 4;
const BIG_PAD_X = 30;
// A number row in the big look (Integer and Float): one large centred number
// between a step arrow on each side, in a field as wide as the card, with the
// gear in its corner as on Text.
const BIG_NUMBER_FONT = 30;
export function ensureCardCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-card{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none}
.ausboss-card-row{display:grid;grid-template-columns:minmax(56px,30%) 1fr;align-items:center;gap:8px;height:${ROW_HEIGHT}px;flex:none;pointer-events:auto}
.ausboss-card-row.hidden{display:none}
.ausboss-card-row.linked{opacity:.5}
.ausboss-card-row.linked .ausboss-card-control{pointer-events:none}
.ausboss-card-row.muted .ausboss-card-control{opacity:.35;pointer-events:none}
.ausboss-card-row.muted .ausboss-card-label{color:#566b69}
.ausboss-card-range{margin-left:6px;color:#5f7674;font-size:10px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
.ausboss-card.has-corner .ausboss-card-control{margin-right:var(--ausboss-card-corner,0px)}
.ausboss-card-corner{position:absolute;top:${CARD_PADDING}px;right:8px;height:${ROW_HEIGHT}px;display:flex;align-items:center;justify-content:flex-end;gap:5px;pointer-events:auto}
.ausboss-card-gear{width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0f1516;color:#8ba3a1;cursor:pointer}
.ausboss-card-gear:hover{border-color:${BRAND};color:${BRAND}}
.ausboss-card-chip{height:20px;flex:none;padding:0 8px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:600 10px/1 ${FONT};letter-spacing:.02em;white-space:nowrap;cursor:pointer}
.ausboss-card-chip:hover{background:rgba(0,180,170,.24);color:#fff}
.ausboss-card-chip[hidden]{display:none}
.ausboss-card-row.area{display:block}
.ausboss-card-row.panel{display:block}
.ausboss-card-row.area.grow{flex:1 1 auto;height:auto}
.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#8ba3a1;font-size:11px;user-select:none}
.ausboss-card-control{display:flex;align-items:center;gap:6px;min-width:0;height:100%}
.ausboss-card-field{display:flex;align-items:center;gap:6px;flex:1 1 0;min-width:0;height:100%}
.ausboss-card-field.linked{opacity:.5;pointer-events:none}
.ausboss-card-field>.ausboss-scrub{flex:1 1 0;width:auto;min-width:0}
.ausboss-card-field>.ausboss-scrub .ausboss-scrub-input{width:100%}
.ausboss-card-sep{flex:none;color:#5f7674;font-size:11px}
.ausboss-card-suffix{flex:none;color:#5f7674;font-size:10px;letter-spacing:.06em;text-transform:uppercase;user-select:none}
.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none}
.ausboss-card-section.hidden{display:none}
.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:6px;background:transparent;color:#78908e;font:600 10.5px/1 ${FONT};letter-spacing:.06em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto}
.ausboss-card-group:hover{color:#fff;border-color:#2a3437}
.ausboss-card-group .glyph{display:inline-block;width:10px;transition:transform .12s}
.ausboss-card-group.open .glyph{transform:rotate(90deg)}
.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0f1516}
.ausboss-card-seg button{min-width:0;border:none;border-radius:4px;padding:0 2px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-seg button:hover{color:#fff}
.ausboss-card-seg button.on{background:${BRAND};color:#04201d}
.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238ba3a1' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 8px center;color:#d8ecea;font:600 11.5px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-select:hover{border-color:${BRAND}}
.ausboss-card-select:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px ${FONT};min-width:0}
.ausboss-card-text:focus{outline:none;border-color:${BRAND}}
.ausboss-card-text::placeholder{color:#4f6462}
.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0}
.ausboss-card-area:focus{outline:none;border-color:${BRAND}}
.ausboss-card-area::placeholder{color:#4f6462}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field{position:relative;display:block;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:text}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field:focus-within{border-color:${BRAND}}
.ausboss-card.look-big .ausboss-card-area{position:absolute;left:0;right:0;top:0;bottom:0;margin:auto 0;max-height:100%;padding:${BIG_PAD_Y}px ${BIG_PAD_X}px;border:none;border-radius:5px;background:transparent;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_FONT}px/${BIG_LINE}px ${FONT};text-align:center;scrollbar-width:thin;scrollbar-color:#2f3d3c transparent}
.ausboss-card.look-big .ausboss-card-area::placeholder{color:#4f6462;text-shadow:none}
.ausboss-card.look-big .ausboss-card-row:not(.area){display:block}
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-label{display:none}
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-control{margin:0}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub{height:100%;justify-content:center;align-items:center;gap:4px;border-color:#2a3437;border-radius:6px;background:#0b0f10}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub:focus-within{border-color:${BRAND}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-input{order:1;flex:0 0 auto;width:auto;min-width:2ch;max-width:60%;field-sizing:content;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_NUMBER_FONT}px ${FONT}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit{order:2}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit:empty{display:none}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step{display:contents}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button{flex:none;width:30px;height:30px;border-radius:15px;color:#6f8886}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:first-child{order:3}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:last-child{order:0}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button>svg{transform:rotate(90deg) scale(1.6)}
.ausboss-card.look-big .ausboss-card-corner{top:${CARD_PADDING + 4}px;right:12px;height:22px}
.ausboss-card.look-big .ausboss-card-gear{width:22px;height:22px;border-color:transparent;background:transparent}
.ausboss-card.look-big .ausboss-card-gear:hover{border-color:${BRAND}}
.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:pointer}
.ausboss-card-swatch::-webkit-color-swatch-wrapper{padding:3px}
.ausboss-card-swatch::-webkit-color-swatch{border:none;border-radius:3px}
.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid #2a3437;border-radius:6px;background:#16201f;color:#d8ecea;font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap}
.ausboss-card-btn:hover{border-color:${BRAND};color:#fff}
`;
document.head.append(style);
}
// ---------- DOM ----------
function el(tag, className = "", text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name) ?? null;
}
function widgetSlot(node, name) {
return node.inputs?.find((input) => input?.widget?.name === name || input?.name === name) ?? null;
}
// A `top: true` row's widgets keep their input slots but the slots stop
// being widget inputs: the frontend then measures and draws them in the
// node's slot column, and a link into one is resolved by input name.
export function liftSocket(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name);
if (!slot) return false;
delete slot.widget;
slot._widget = undefined;
return true;
}
function widgetLinked(node, name) {
const slot = widgetSlot(node, name);
return Boolean(slot && slot.link !== null && slot.link !== undefined);
}
function isFloatWidget(widget) {
const precision = widget?.options?.precision;
if (precision !== undefined) return Number(precision) > 0;
const step = widget?.options?.step2 ?? widget?.options?.round;
return step !== undefined ? !Number.isInteger(Number(step)) : !Number.isInteger(Number(widget?.value));
}
// Mount a card on `node`. Rows:
// { widget, label?, kind?, labels?, suffix?, title?, when?, group?, linkedBy?, snap?, pixels?, step?, limits?, trimZeros? }
// - `linkedBy` names other inputs
// whose link also dims the row
// (a wired source replacing a
// file picker); `snap` steps a
// number on multiples of its
// step (1, 8, 16, not 1, 9, 17);
// a whole number steps by 1
// unless the row is a pixel size
// (`suffix: "px"` or `pixels`),
// which steps by 8, or names
// its own `step`; `limits` is a
// function of the node that
// gives { min, max, step } from
// somewhere else (what a number
// box is wired to), or null;
// `trimZeros` drops the zeroes
// the steps never reach (1.00,
// not 1.000)
// { widget, kind: "textarea", placeholder?, height, grow?, looks? }
// - a multiline string; `grow` takes
// the node's spare height; `looks`
// holds the height and placeholder
// the row takes in another look
// { pair: [name, name, ...], label, sep?, when?, group?, suffix?, top? }
// - `top` lifts the widgets' sockets
// into the node's slot column
// { section: "Caption" }
// { panel: (node) => ({ element, sync(values) }), height }
// - the node's own display, on a row
// of the card
// { group: "advanced", label: "More" } - a disclosure; later rows with
// group: "advanced" sit under it
// Rows may also carry `mute: (values) => boolean` and `muteTitle`: a muted
// row stays in place but dims and stops taking input, with the title as its
// hover hint (a setting the current mode does not use).
// Options: minWidth (node floor), first (place the card ahead of the node's
// other DOM widgets), hide (extra widgets to hide), corner (small tools in
// the card's top-right corner, see below), look (a function of the node that
// names the card's look, a display choice kept on the node; the card carries
// it as a `look-<name>` class and rows take their `looks` entry for it).
export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hide = [], corner = null, look: lookOf = null } = {}) {
ensureCardCss();
const root = el("div", "ausboss-card");
const managed = [];
const state = { node, root, rows: [], groups: {}, height: 0, widget: null, rowTop: new Map(), rowSpan: new Map(), socketed: [], pickers: [], observers: [], look: "" };
node.__ausbossCard = state;
const values = () => Object.fromEntries((node.widgets ?? []).map((widget) => [widget.name, widget.value]));
const groupProperty = (name) => `ausboss_show_${name}`;
const groupOpen = (name) => Boolean(node.properties?.[groupProperty(name)]);
// What the person changes on the card itself. A row it brings in or takes
// out moves the node's height with it (nodeHeightAfterCardChange).
const byHand = (run) => {
state.byHand = true;
try { return run(); } finally { state.byHand = false; }
};
const setGroupOpen = (name, open) => {
node.properties ??= {};
node.properties[groupProperty(name)] = Boolean(open);
byHand(refresh);
};
const setWidget = (name, value, { settle = true } = {}) => {
const widget = findWidget(node, name);
if (!widget || widget.value === value) return;
byHand(() => commitWidgetValue(node, widget, value, globalThis.app?.canvas));
node.graph?.setDirtyCanvas?.(true, true);
if (settle) notifyAusbossChange();
};
// Every row keeps a `sync(values, linked)` that writes the widget's value
// into its control, so a refresh is one loop.
const buildScrub = (name, row, into) => {
const widget = findWidget(node, name);
const isFloat = isFloatWidget(widget);
const steps = scrubSteps(widget?.options, isFloat, row.pixels ?? row.suffix === "px");
// `trimZeros`: the box shows the decimals its arrow step reaches (at
// least one) and only goes past them for a value that uses more.
const shownDecimals = (step, held) => (row.trimZeros ? Math.min(held, Math.max(1, stepDecimals(step))) : null);
const control = makeScrubInput({
value: Number(widget?.value) || 0,
min: Number(widget?.options?.min ?? -Infinity),
max: Number(widget?.options?.max ?? Infinity),
step: row.step ?? steps.step,
fineStep: steps.fineStep,
decimals: row.decimals ?? steps.decimals,
minDecimals: shownDecimals(row.step ?? steps.step, row.decimals ?? steps.decimals),
title: row.title ?? widgetTooltip(node, name, widget),
unit: row.suffix ?? "",
// Single fields share one unit slot so their numbers line up down
// the card; the fields of a pair sit side by side and keep the room.
unitWidth: row.pair ? 0 : UNIT_SLOT_WIDTH,
snap: Boolean(row.snap),
onChange: (next) => setWidget(name, isFloat ? next : Math.round(next), { settle: false }),
onSettle: () => notifyAusbossChange(),
});
into.append(control.root);
if (typeof row.limits !== "function") return (vals) => control.set(Number(vals[name]) || 0);
// Limits that come from somewhere else are read at every refresh: a wire
// made or cut changes them. The stored value follows the range, the way
// ComfyUI's own Primitive node follows the input it is wired to.
let shown = null;
const hint = el("span", "ausboss-card-range");
return (vals) => {
const outside = row.limits(node) ?? null;
const range = narrowedRange(widget?.options, outside);
const key = outside ? `${range.min}|${range.max}|${outside.step}` : "";
if (key !== shown) {
shown = key;
const next = scrubSteps({ ...widget?.options, ...range, ...(outside?.step ? { step2: outside.step, precision: undefined } : {}) }, isFloat, row.pixels ?? row.suffix === "px");
// A step finer than the box shows (0.0005 in a box of three decimals) gets the decimals it needs.
const decimals = row.decimals ?? steps.decimals;
const held = outside?.step ? Math.max(decimals, next.decimals) : decimals;
const step = row.step ?? next.step;
control.setRange({ ...range, step, fineStep: next.fineStep, decimals: held, minDecimals: shownDecimals(step, held) });
const label = into.closest(".ausboss-card-row")?.querySelector(".ausboss-card-label");
hint.textContent = limitsLabel(outside ? range : null);
hint.title = outside ? `The limits of what this is wired to: ${outside.names.join(", ")}.` : "";
if (label && hint.parentNode !== label) label.append(hint);
}
const value = Number(vals[name]) || 0;
const held = quantizeScrubValue(value, { ...range, decimals: isFloat ? 6 : 0 });
if (outside && held !== value) queueMicrotask(() => setWidget(name, held, { settle: false }));
control.set(held);
};
};
const buildSegment = (name, row, into) => {
const widget = findWidget(node, name);
const seg = el("div", "ausboss-card-seg");
const buttons = new Map();
const add = (value, values) => {
const button = el("button", "", choiceLabel(row, value, values));
button.type = "button"; button.title = row.titles?.[value] ?? String(value);
button.addEventListener("click", () => setWidget(name, value));
seg.append(button); buttons.set(value, button);
};
const values = comboValues(widget);
for (const value of values) add(value, values);
into.append(seg);
return (vals) => {
// A value the list does not offer (a model file this machine does not
// have) still shows as what is chosen, as it does in a list.
const current = vals[name];
if (typeof current === "string" && current !== "" && !buttons.has(current)) add(current, comboValues(widget));
for (const [value, button] of buttons) button.classList.toggle("on", current === value);
};
};
const buildSelect = (name, row, into) => {
const widget = findWidget(node, name);
// A file source (`preview: { kind, url }`) gets the media picker: a list
// that previews the hovered file, which a native <select> cannot do.
if (row.preview) {
const noun = row.preview.kind === "video" ? "video" : "image";
const picker = createMediaPicker({
kind: noun,
className: "ausboss-card-select",
placeholder: row.preview.placeholder ?? `Choose an uploaded ${noun}…`,
viewUrl: row.preview.url,
label: row.label,
getOptions: () => comboValues(widget),
getValue: () => widget?.value,
onChange: (value) => setWidget(name, value),
});
state.pickers.push(picker);
into.append(picker.element);
return (vals) => picker.refresh(vals[name]);
}
const select = el("select", "ausboss-card-select");
select.title = row.title ?? widgetTooltip(node, name, widget);
const fill = (current) => {
select.textContent = "";
const options = comboValues(widget);
const list = options.includes(current) || current === undefined || current === "" ? options : [...options, current];
for (const value of list) {
const option = el("option", "", choiceLabel(row, value, options)); option.value = String(value); select.append(option);
}
select.value = String(current ?? "");
};
select.addEventListener("change", () => setWidget(name, select.value));
into.append(select);
return (vals) => fill(vals[name]);
};
// A boolean is a two-segment pill, off on the left and on on the right,
// as wide as any other control on the card; the short `offText` /
// `onText` name the states ("off | embed workflow"), longer descriptions
// ride on the tooltip.
const buildSwitch = (name, row, into) => {
const widget = findWidget(node, name);
const seg = el("div", "ausboss-card-seg ausboss-card-bool");
seg.setAttribute("role", "radiogroup");
const label = (text, fallback) => (typeof text === "string" && text.length <= SWITCH_LABEL_MAX ? text : fallback);
const buttons = new Map();
for (const [value, text] of [[false, label(row.offText, "off")], [true, label(row.onText, "on")]]) {
const button = el("button", "", text);
button.type = "button";
button.title = row.title ?? (widgetTooltip(node, name, widget) || (value ? row.onText ?? "" : row.offText ?? ""));
button.addEventListener("click", () => setWidget(name, value));
seg.append(button); buttons.set(value, button);
}
into.append(seg);
return (vals) => {
const on = Boolean(vals[name]);
for (const [value, button] of buttons) {
button.classList.toggle("on", value === on);
button.setAttribute("aria-checked", String(value === on));
}
};
};
const buildText = (name, row, into) => {
const widget = findWidget(node, name);
const input = el("input", "ausboss-card-text");
input.type = "text"; input.placeholder = row.placeholder ?? ""; input.title = row.title ?? widgetTooltip(node, name, widget);
input.spellcheck = false;
const commit = () => { if (input.value !== String(findWidget(node, name)?.value ?? "")) setWidget(name, input.value); };
input.addEventListener("change", commit);
input.addEventListener("blur", commit);
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") input.blur();
if (event.key === "Escape") { input.value = String(findWidget(node, name)?.value ?? ""); input.blur(); }
});
into.append(input);
return (vals) => { if (document.activeElement !== input) input.value = String(vals[name] ?? ""); };
};
// A multiline string. The frontend's own textarea for the widget stays
// mounted (hidden) underneath, so dynamic prompts, serialization and the
// widget's input socket are all still its business; this box only edits
// the same value.
const buildTextarea = (name, row, into) => {
const widget = findWidget(node, name);
if (widget?.element) widget.element.style.display = "none";
const area = el("textarea", "ausboss-card-area");
area.placeholder = row.placeholder ?? row.label ?? name;
area.title = row.title ?? widgetTooltip(node, name, widget);
area.spellcheck = false;
let typing = false;
// In the big look the box is as tall as its text, up to the field's
// height, and sits in the middle of the field (out of flow, so the text
// never sizes the card in Nodes 2.0); past that it scrolls. While typing
// at the end, the end stays in view.
const fit = ({ toEnd = false } = {}) => {
if (state.look !== BIG_LOOK) {
if (area.style.height) area.style.height = "";
return;
}
const keep = area.scrollTop;
area.style.height = "0px";
area.style.height = `${area.scrollHeight}px`;
area.scrollTop = toEnd && area.selectionEnd === area.value.length ? area.scrollHeight : keep;
};
// The text wraps again when the node is made wider or narrower.
if (typeof ResizeObserver === "function") {
const observer = new ResizeObserver(() => fit());
observer.observe(into);
state.observers.push(observer);
}
// The big field is all text box: a click above or below the words puts
// the caret at their end.
into.addEventListener("pointerdown", (event) => {
if (state.look !== BIG_LOOK || event.target !== into || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
area.focus();
area.setSelectionRange(area.value.length, area.value.length);
});
area.addEventListener("input", () => { typing = true; setWidget(name, area.value, { settle: false }); typing = false; fit({ toEnd: true }); });
area.addEventListener("change", () => notifyAusbossChange());
area.addEventListener("keydown", (event) => {
// Ctrl + Up / Down goes on to ComfyUI, which changes the weight of the
// selected words; what it writes comes back through "input" above.
keepKeyInField(event, { weights: true });
if (event.key === "Escape") area.blur();
});
// The box scrolls its own overflow; the canvas only zooms once it is
// at an end.
area.addEventListener("wheel", (event) => {
const down = event.deltaY > 0;
const atEnd = down ? area.scrollTop + area.clientHeight >= area.scrollHeight - 1 : area.scrollTop <= 0;
if (!atEnd) event.stopPropagation();
}, { passive: true });
into.append(area);
return (vals) => {
if (!typing && document.activeElement !== area) area.value = String(vals[name] ?? "");
area.placeholder = rowInLook(row, state.look).placeholder ?? row.label ?? name;
fit();
};
};
const buildColor = (name, row, into) => {
const widget = findWidget(node, name);
const swatch = el("input", "ausboss-card-swatch"); swatch.type = "color";
swatch.title = "Pick a color";
const input = el("input", "ausboss-card-text"); input.type = "text"; input.spellcheck = false;
input.title = row.title ?? widgetTooltip(node, name, widget);
const commit = () => { if (input.value !== String(findWidget(node, name)?.value ?? "")) setWidget(name, input.value.trim()); };
swatch.addEventListener("input", () => { input.value = swatch.value; setWidget(name, swatch.value, { settle: false }); });
swatch.addEventListener("change", () => notifyAusbossChange());
input.addEventListener("change", commit); input.addEventListener("blur", commit);
input.addEventListener("keydown", (event) => { keepKeyInField(event); if (event.key === "Enter") input.blur(); });
into.append(swatch, input);
return (vals) => {
const text = String(vals[name] ?? "");
if (document.activeElement !== input) input.value = text;
if (/^#[0-9a-f]{6}$/i.test(text)) swatch.value = text.toLowerCase();
};
};
const buildButton = (row, into) => {
const button = el("button", "ausboss-card-btn", row.text ?? "Go");
button.type = "button"; button.title = row.title ?? "";
button.addEventListener("click", () => row.onClick?.(node, state));
into.append(button);
};
const builders = { scrub: buildScrub, segment: buildSegment, select: buildSelect, switch: buildSwitch, text: buildText, textarea: buildTextarea, color: buildColor };
for (const name of hide) { const widget = findWidget(node, name); if (widget) { hideWidget(widget); managed.push(name); } }
const topNames = new Set(rows.filter((row) => row.top).flatMap((row) => row.pair ?? (row.widget ? [row.widget] : [])));
for (const name of topNames) liftSocket(node, name);
let currentGroup = null;
for (const row of rows) {
if (row.section !== undefined) {
const caption = el("div", "ausboss-card-section", row.section);
state.rows.push({ row: { ...row, inGroup: currentGroup }, element: caption, syncs: [] });
root.append(caption); continue;
}
if (row.panel) {
const line = el("div", "ausboss-card-row panel");
line.style.height = `${rowHeight(row)}px`;
const panel = row.panel(node);
line.append(panel.element);
state.rows.push({ row, element: line, syncs: panel.sync ? [panel.sync] : [] });
root.append(line); continue;
}
if (row.group !== undefined && row.widget === undefined && row.pair === undefined) {
currentGroup = row.group;
const header = el("button", "ausboss-card-group");
header.type = "button"; header.title = row.title ?? "";
header.append(el("span", "glyph", "▸"), el("span", "", row.label ?? row.group));
header.addEventListener("click", () => setGroupOpen(row.group, !groupOpen(row.group)));
state.rows.push({ row, element: header, header: true, syncs: [] });
root.append(header); continue;
}
const names = row.pair ?? [row.widget];
for (const name of names) { const widget = findWidget(node, name); if (widget) { hideWidget(widget); managed.push(name); } }
const line = el("div", "ausboss-card-row");
const control = el("div", "ausboss-card-control");
const syncs = [];
const isArea = row.kind === "textarea";
if (isArea) {
line.classList.add("area");
if (row.grow) { line.classList.add("grow"); line.style.minHeight = `${rowHeight(row)}px`; }
else line.style.height = `${rowHeight(row)}px`;
} else {
const label = el("span", "ausboss-card-label", row.label ?? names[0]);
label.title = row.title ?? widgetTooltip(node, names[0], findWidget(node, names[0]));
line.append(label);
}
const fields = new Map();
names.forEach((name, index) => {
if (index > 0) control.append(el("span", "ausboss-card-sep", row.sep ?? "·"));
const widget = findWidget(node, name);
const kind = rowKind(row, widget);
if (kind === "skip" || !widget) return;
if (row.prefixes?.[index]) control.append(el("span", "ausboss-card-sep", row.prefixes[index]));
const field = el("div", "ausboss-card-field");
syncs.push(builders[kind](name, row, field));
control.append(field);
fields.set(name, field);
});
if (row.button) buildButton(row.button, control);
line.append(control);
state.rows.push({ row: { ...row, inGroup: row.group ?? null }, element: line, names, syncs, fields });
root.append(line);
}
// ---------- corner tools ----------
// `corner(node, { values, setWidget })` returns { element, sync(values) }:
// tools that sit in the card's top-right corner, level with the first row
// (a gear, a mode chip). sync returns how many pixels they need, and every
// row's control keeps that much clear, so the controls down the card still
// share one right edge.
// `refresh` redraws the card after a change the tools made on the node
// itself (a look kept in its properties), as a change by hand.
const tools = typeof corner === "function" ? corner(node, { values, setWidget, refresh: () => byHand(refresh) }) : null;
if (tools?.element) {
const holder = el("div", "ausboss-card-corner");
holder.append(tools.element);
root.classList.add("has-corner");
root.append(holder);
}
// Clicks on controls stay out of the graph's drag; empty card space falls through.
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select,textarea,.ausboss-scrub")) event.stopPropagation();
});
const grows = rows.some((row) => row.grow);
const widget = node.addDOMWidget(CARD_WIDGET, CARD_WIDGET, root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => state.height + WRAPPER_INSET,
});
keepDomWidgetWidthAuto(widget);
// The card is not a value, so it is not saved with the workflow either.
// options.serialize only keeps it out of the prompt; saves skip a widget
// whose own serialize is false. Saved values come back by position, and
// the "" a card saved before this lands on any input appended after it,
// which is why cards still carry resetUnknown for older workflows.
widget.serialize = false;
state.widget = widget;
if (grows) {
// A card with a growing textarea is a viewport: it declares a floor and
// takes the node's spare height, the textarea flexing to fill it.
fillNodeHeight(widget, {
minWidth,
minHeight: () => state.height + WRAPPER_INSET,
minNodeSize: [minWidth, 60],
exactMinWidth: true,
});
} else {
// A constant-height card, pinned on purpose (tests/panel_guards.test.mjs,
// fixedByDesign): its height is the sum of its rows, not a viewport.
widget.computeSize = (width) => [Math.max(minWidth, Number(width || node.size?.[0] || minWidth)), state.height + WRAPPER_INSET];
// Pinned in Nodes 2.0 as well, so a picture panel under the card takes
// the node's spare height there too.
pinVuePanelHeight(widget);
widget.options.minNodeSize = [minWidth, 60];
// The frontend never reads that width for a pinned widget: the card's
// number has to be made the node's floor by hand.
holdNodeMinWidth(widget, minWidth);
}
if (first && Array.isArray(node.widgets)) {
const index = node.widgets.indexOf(widget);
const firstDom = node.widgets.findIndex((item) => item !== widget && item.element);
if (index >= 0 && firstDom >= 0 && firstDom < index) {
node.widgets.splice(index, 1); node.widgets.splice(firstDom, 0, widget);
}
}
// ---------- sockets ----------
// Each hidden widget with an input slot reports the y of its row, so the
// frontend draws (and hit-tests) its socket there. A row folded away
// hands its socket to its group header, or to the card's top edge.
const socketY = (name) => {
if (state.disposed) return null;
const target = findWidget(node, name);
if (!target?.hidden) return null;
const cardY = Number(state.widget?.y);
if (!Number.isFinite(cardY)) return null;
const top = state.rowTop.get(name);
const span = state.rowSpan.get(name);
if (top === undefined) return cardY + WRAPPER_TOP - SLOT_OFFSET;
return socketWidgetY(cardY, WRAPPER_TOP, top, span ?? ROW_HEIGHT);
};
for (const name of new Set(managed)) {
if (topNames.has(name)) continue;
const target = findWidget(node, name);
if (!target || !widgetSlot(node, name)) continue;
const own = Object.getOwnPropertyDescriptor(target, "y");
if (own?.get) continue;
let stored = target.y;
try {
Object.defineProperty(target, "y", {
configurable: true, enumerable: true,
get: () => socketY(name) ?? stored,
set: (value) => { stored = value; },
});
state.socketed.push({ widget: target, restore: () => { delete target.y; target.y = stored; } });
} catch { /* sealed widget: its socket stays wherever the frontend leaves it */ }
}
// While a link is being dragged, the row under the pointer stands in for
// its widget, so a drop anywhere on the row lands on that widget's input.
// Outside a drag the card is one widget to the canvas, as before.
const rowWidgetAt = (canvasX, canvasY, { lifted = false } = {}) => {
const cardY = Number(state.widget?.y);
if (!Number.isFinite(cardY)) return null;
const localY = canvasY - node.pos[1] - cardY - WRAPPER_TOP;
for (const entry of state.rows) {
if (!entry.names?.length || Boolean(entry.row.top) !== lifted) continue;
const top = state.rowTop.get(entry.names[0]);
if (top === undefined) continue;
const span = state.rowSpan.get(entry.names[0]) ?? ROW_HEIGHT;
if (localY < top || localY > top + span) continue;
if (entry.names.length === 1) return entry.names[0];
// A pair: the field under the pointer, by its on-screen box.
const canvas = globalThis.app?.canvas;
const rect = canvas?.canvas?.getBoundingClientRect?.();
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
const clientX = rect ? rect.left + (canvasX + offset[0]) * scale : null;
const boxes = [...entry.element.querySelectorAll(".ausboss-scrub, .ausboss-card-text, .ausboss-card-select")];
const hit = clientX === null ? -1 : boxes.findIndex((box) => { const r = box.getBoundingClientRect(); return clientX >= r.left && clientX <= r.right; });
return entry.names[Math.max(0, Math.min(entry.names.length - 1, hit))];
}
return null;
};
const originalGetWidgetOnPos = node.getWidgetOnPos;
if (typeof originalGetWidgetOnPos === "function") {
node.getWidgetOnPos = function (canvasX, canvasY, includeDisabled) {
const found = originalGetWidgetOnPos.call(this, canvasX, canvasY, includeDisabled);
if (state.disposed || found !== state.widget) return found;
if (!globalThis.app?.canvas?.linkConnector?.isConnecting) return found;
const name = rowWidgetAt(canvasX, canvasY);
return (name && findWidget(this, name)) || found;
};
}
// The fields of a pair share one socket on their row's left edge. A drop
// on that socket goes to the first field of the pair still free, so a
// second link does not silently replace the first; to aim at one field,
// drop on the field itself.
// A `top` row's fields have their sockets up in the slot column, but a
// drop on the field itself still means that field.
const originalGetInputOnPos = node.getInputOnPos;
if (typeof originalGetInputOnPos === "function") {
node.getInputOnPos = function (pos) {
const input = originalGetInputOnPos.call(this, pos);
if (state.disposed) return input;
if (!input && globalThis.app?.canvas?.linkConnector?.isConnecting) {
const topName = rowWidgetAt(pos[0], pos[1], { lifted: true });
if (topName) return widgetSlot(this, topName) ?? input;
}
const name = input?.widget?.name;
if (!name || input.link === null || input.link === undefined) return input;
const entry = state.rows.find((item) => item.names?.length > 1 && !item.row.top && item.names.includes(name));
if (!entry) return input;
const free = entry.names.find((other) => { const slot = widgetSlot(this, other); return slot && (slot.link === null || slot.link === undefined); });
return free ? widgetSlot(this, free) : input;
};
}
function refresh() {
if (state.disposed) return;
const vals = values();
const look = typeof lookOf === "function" ? String(lookOf(node) ?? "") : "";
const lookChanged = look !== state.look;
if (lookChanged) {
if (state.look) root.classList.remove(`look-${state.look}`);
if (look) root.classList.add(`look-${look}`);
state.look = look;
// A textarea row, and any row with a height of its own in some look,
// takes its height in this look.
for (const entry of state.rows) {
if (entry.row.kind !== "textarea" && !entry.row.looks) continue;
const height = `${rowHeight(entry.row, look)}px`;
if (entry.row.grow) entry.element.style.minHeight = height;
else entry.element.style.height = height;
}
}
const groups = {};
for (const entry of state.rows) if (entry.header) groups[entry.row.group] = groupOpen(entry.row.group);
const visible = visibleRows(state.rows.map((entry) => entry.row), vals, groups);
const visibleSet = new Set(visible);
const tops = rowTops(visible, look);
state.rowTop = new Map();
state.rowSpan = new Map();
visible.forEach((row, index) => {
const entry = state.rows.find((item) => item.row === row);
for (const name of entry?.names ?? []) { state.rowTop.set(name, tops[index]); state.rowSpan.set(name, rowHeight(row, look)); }
if (entry?.header) { state.rowTop.set(`group:${row.group}`, tops[index]); }
});
// A row folded under a closed group: its socket rides the header.
for (const entry of state.rows) {
if (!entry.names || visibleSet.has(entry.row) || !entry.row.inGroup) continue;
const headerTop = state.rowTop.get(`group:${entry.row.inGroup}`);
if (headerTop === undefined) continue;
for (const name of entry.names) { state.rowTop.set(name, headerTop); state.rowSpan.set(name, GROUP_HEIGHT); }
}
for (const entry of state.rows) {
const show = visibleSet.has(entry.row);
entry.element.classList.toggle("hidden", !show);
if (entry.header) entry.element.classList.toggle("open", groups[entry.row.group]);
if (!show) continue;
// A single field dims with its row; in a pair only the linked field
// dims, the others stay editable.
const linkedNames = (entry.names ?? []).filter((name) => widgetLinked(node, name));
const linked = (linkedNames.length > 0 && linkedNames.length === (entry.names ?? []).length)
|| (entry.row.linkedBy ?? []).some((name) => widgetLinked(node, name));
entry.element.classList.toggle("linked", linked);
for (const [name, field] of entry.fields ?? []) field.classList.toggle("linked", linkedNames.includes(name));
if (entry.row.mute) {
const muted = rowMuted(entry.row, vals);
entry.element.classList.toggle("muted", muted);
const label = entry.element.querySelector(".ausboss-card-label");
entry.baseTitle ??= label?.title ?? "";
const hint = muted ? (entry.row.muteTitle ?? "") : "";
entry.element.title = hint;
if (label) label.title = hint || entry.baseTitle;
}
for (const sync of entry.syncs) sync(vals, linked);
}
if (tools?.sync) root.style.setProperty("--ausboss-card-corner", `${Math.max(0, Number(tools.sync(vals)) || 0)}px`);
const height = cardHeight(visible, look);
if (height !== state.height) {
const first = !state.height;
const change = first ? 0 : height - state.height;
state.height = height;
const width = Math.max(minWidth, node.size?.[0] || minWidth);
const floor = node.computeSize?.()[1] || height;
const restore = cardChangeRestore({
restoring: state.restoring, savedHeight: state.savedHeight, lookChanged, byHand: state.byHand, current: node.size?.[1],
});
node.setSize?.([width, nodeHeightAfterCardChange({
floor, current: node.size?.[1], saved: restore.saved, change, first, restoring: restore.restoring, byHand: state.byHand,
})]);
node.graph?.setDirtyCanvas?.(true, true);
}
node._widgetSlotsDirty = true;
}
for (const name of new Set(managed)) {
const target = findWidget(node, name);
if (target) chainCallback(target, "callback", () => refresh());
}
chainCallback(node, "onConfigure", (info) => {
// The node now has its saved size, and that size already holds every row
// the saved values show. Marked here, not in the microtask below: a
// refresh queued earlier in the load (a restored link) runs before it.
// The height is kept too: before that microtask the frontend grows the
// node to fit the card as it still is, with the rows of a new node.
state.restoring = true;
state.savedHeight = Number(info?.size?.[1]);
queueMicrotask(() => {
try {
for (const name of managed) {
const target = findWidget(node, name);
hideWidget(target);
if (target?.element && state.rows.some((entry) => entry.row.kind === "textarea" && entry.names?.includes(name))) target.element.style.display = "none";
}
for (const name of topNames) liftSocket(node, name);
refresh();
} finally {
state.restoring = false;
state.savedHeight = undefined;
}
// A workflow saved before this card existed sized the node for the
// classic widgets; give the card and any panel below it their floors.
ensureNodeMinHeight(node);
});
});
chainCallback(node, "onConnectionsChange", () => queueMicrotask(refresh));
chainCallback(node, "onRemoved", () => {
state.disposed = true;
for (const picker of state.pickers) picker.dispose();
for (const observer of state.observers) observer.disconnect();
for (const entry of state.socketed) entry.restore();
if (typeof originalGetWidgetOnPos === "function") delete node.getWidgetOnPos;
if (typeof originalGetInputOnPos === "function") delete node.getInputOnPos;
});
refresh();
state.refresh = refresh; state.setGroupOpen = setGroupOpen; state.groupOpen = groupOpen;
return state;
}