// 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 { createMediaPicker } from "./media_picker.mjs"; import { ensureNodeMinHeight, fillNodeHeight } from "./panel_layout.mjs"; import { makeScrubInput } from "./scrub_input.mjs"; import { hideWidget } from "./widget_visibility.mjs"; import { CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, UNIT_SLOT_WIDTH, cardHeight, commitWidgetValue, comboValues, rowHeight, rowKind, rowMuted, rowTops, scrubSteps, socketWidgetY, visibleRows, } 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; 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.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.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-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? } // - `linkedBy` names other inputs // whose link also dims the row // (a wired source replacing a // file picker) // { widget, kind: "textarea", placeholder?, height, grow? } - a multiline // string; `grow` takes the node's // spare height // { pair: [name, name, ...], label, sep?, when?, group?, suffix?, top? } // - `top` lifts the widgets' sockets // into the node's slot column // { section: "Caption" } // { 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). export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hide = [], corner = 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: [] }; 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)]); const setGroupOpen = (name, open) => { node.properties ??= {}; node.properties[groupProperty(name)] = Boolean(open); refresh(); }; const setWidget = (name, value, { settle = true } = {}) => { const widget = findWidget(node, name); if (!widget || widget.value === value) return; 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); const control = makeScrubInput({ value: Number(widget?.value) || 0, min: Number(widget?.options?.min ?? -Infinity), max: Number(widget?.options?.max ?? Infinity), step: steps.step, fineStep: steps.fineStep, decimals: row.decimals ?? steps.decimals, title: row.title ?? widget?.options?.tooltip ?? "", 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, onChange: (next) => setWidget(name, isFloat ? next : Math.round(next), { settle: false }), onSettle: () => notifyAusbossChange(), }); into.append(control.root); return (vals) => control.set(Number(vals[name]) || 0); }; const buildSegment = (name, row, into) => { const widget = findWidget(node, name); const seg = el("div", "ausboss-card-seg"); const buttons = new Map(); for (const value of comboValues(widget)) { const button = el("button", "", row.labels?.[value] ?? String(value)); button.type = "button"; button.title = row.titles?.[value] ?? String(value); button.addEventListener("click", () => setWidget(name, value)); seg.append(button); buttons.set(value, button); } into.append(seg); return (vals) => { for (const [value, button] of buttons) button.classList.toggle("on", vals[name] === 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