Files
ausboss 9f8bf4d803 Calmer node cards and picture bar on one shared palette (#308)
* Restyle widget cards in solid gray on one shared palette

Cards, number boxes and off|on switches take their colors, font and radii
from js/shared/theme.mjs. Controls are solid gray with no outlines on a
near-black card, grays are neutral, faint text holds 4.5:1, and number boxes
in a card are as tall as the row. A test keeps colour literals out of the
three modules and checks text contrast.

* Lift the card toward the node colour and brighten the off mark

* Restyle the picture bar, its stepper and switch on the shared palette

* Darken the card a step

* Picture frame back to the lighter well; changelog covers the picture bar
2026-11-07 17:41:47 -06:00

417 lines
18 KiB
JavaScript

// The pack's standard number control, Adobe-style: drag left/right on the
// value to scrub it, click to type an exact value, chevron arrows on the
// right step it, ArrowUp/Down step from the keyboard, and Shift always
// means the fine step. First shipped as the LoRA loader's strength box;
// this is that interaction made reusable, so every numeric field in the
// pack can behave the same way (scrubbing is the house norm).
//
// Pure gesture math up top (tested in tests/scrub_input.test.mjs); the DOM
// factory below wires it to a widget-backed value via callbacks:
//
// const control = makeScrubInput({
// value, min, max, step, fineStep, decimals,
// onChange: (v) => ..., // every committed change, any gesture
// onSettle: () => ..., // a gesture finished (scrub released, value
// }); // typed, arrow clicked) - the undo point
// parent.append(control.root); control.set(next); control.get();
//
// An optional number (a bound that may be absent) passes allowEmpty: true.
// It then holds null, shows its placeholder, clears when the typed text is
// erased, and starts a scrub or step from `emptyStart`.
//
// A value that only means something as a multiple ("divisible by 8")
// passes snap: true. Its steps then land on multiples of `step`, so a box
// at 1 goes 8, 16, 24 instead of 9, 17, 25, and back down to its minimum.
// Shift's fine step and typed values are never snapped.
import { keepKeyInField } from "./canvas_passthrough.mjs";
// theme.mjs has no imports, so this module's math stays testable in node:test.
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
// Horizontal pixels of drag per step; small enough to feel light, large
// enough that a shaky click cannot change the value. The dead zone keeps a
// plain click a click (it opens type-in mode instead).
export const SCRUB_DEAD_ZONE = 3;
export const SCRUB_PIXELS_PER_STEP = 4;
// Shift on a box with no finer step (whole pixels, frame counts) slows the
// drag instead: the same step, over this many times the travel.
export const SCRUB_SLOW_FACTOR = 4;
export function isScrubGesture(deltaX, deltaY) {
return Math.abs(deltaX) > SCRUB_DEAD_ZONE && Math.abs(deltaX) >= Math.abs(deltaY);
}
// Clamp into range and round to the control's precision - every value the
// control emits goes through here, so callers never see 0.30000000000000004.
export function quantizeScrubValue(value, { min = -Infinity, max = Infinity, decimals = 2 } = {}) {
const number = Number(value);
if (!Number.isFinite(number)) return quantizeScrubValue(min === -Infinity ? 0 : min, { min, max, decimals });
const clamped = Math.max(min, Math.min(max, number));
const factor = 10 ** Math.max(0, decimals);
return Math.round(clamped * factor) / factor;
}
// A sum typed into a number box: 1024*1.5, 512+64, (1920-64)/2. It takes the
// marks ComfyUI's own number boxes take: + - * / %, brackets, and a sign in
// front of a number. Returns the answer, or null when the text is not a sum
// that comes to a finite number.
const SUM_LIMIT = 200;
const SUM_NUMBER = /(?:\d+\.?\d*|\.\d+)/y;
export function sumValue(text) {
const source = String(text ?? "");
// Every bracket and sign is one call deep, so the length caps the depth.
if (source.length > SUM_LIMIT) return null;
let at = 0;
const skip = () => { while (source[at] === " " || source[at] === "\t") at += 1; };
// A number, a sum in brackets, or either with a sign in front.
const atom = () => {
skip();
const mark = source[at];
if (mark === "+" || mark === "-") {
at += 1;
const value = atom();
return value === null ? null : mark === "-" ? -value : value;
}
if (mark === "(") {
at += 1;
const value = sum();
skip();
if (value === null || source[at] !== ")") return null;
at += 1;
return value;
}
SUM_NUMBER.lastIndex = at;
const match = SUM_NUMBER.exec(source);
if (!match) return null;
at += match[0].length;
return Number(match[0]);
};
const product = () => {
let value = atom();
while (value !== null) {
skip();
const mark = source[at];
if (mark !== "*" && mark !== "/" && mark !== "%") break;
at += 1;
const next = atom();
if (next === null) return null;
value = mark === "*" ? value * next : mark === "/" ? value / next : value % next;
}
return value;
};
function sum() {
let value = product();
while (value !== null) {
skip();
const mark = source[at];
if (mark !== "+" && mark !== "-") break;
at += 1;
const next = product();
if (next === null) return null;
value = mark === "+" ? value + next : value - next;
}
return value;
}
const value = sum();
skip();
if (value === null || at !== source.length || !Number.isFinite(value)) return null;
return value === 0 ? 0 : value;
}
// How many decimals a step reaches: 0.05 and 0.01 reach two, 0.5 one, 8 none.
export function stepDecimals(step) {
const text = String(Number(Math.abs(Number(step) || 0).toFixed(6)));
const dot = text.indexOf(".");
return dot < 0 ? 0 : text.length - dot - 1;
}
// The text a value is shown as. `decimals` is how many the control holds.
// With `minDecimals`, zeroes past that many are dropped, so a box that steps
// by 0.01 reads 1.00 and still shows a typed 0.855 in full.
export function formatScrubValue(value, { decimals = 2, minDecimals = null } = {}) {
if (value === null || value === undefined) return "";
const held = Math.max(0, decimals);
const text = Number(value).toFixed(held);
if (minDecimals === null || minDecimals === undefined || minDecimals >= held) return text;
const keep = text.length - (held - Math.max(0, minDecimals));
let end = text.length;
while (end > keep && text[end - 1] === "0") end -= 1;
return text[end - 1] === "." ? text.slice(0, end - 1) : text.slice(0, end);
}
// `count` steps of `size` from `start` (negative goes down). With `snap` the
// first step goes to the next multiple of `size` that way and the rest stay
// on those multiples: from 1, +1 is 8 and +2 is 16; from 9, -1 is 8.
export function steppedValue(start, count, size, snap = false) {
const number = Number(start);
if (!snap || !count) return number + count * size;
// The tolerance keeps a value already on the grid there (0.3 / 0.1 is
// 2.9999999999999996, which must not floor to 2).
const units = number / size;
const from = count > 0 ? Math.floor(units + 1e-9) : Math.ceil(units - 1e-9);
return (from + count) * size;
}
// Value under the pointer during a scrub: steps of `step` (or `fineStep`
// with Shift) per SCRUB_PIXELS_PER_STEP of travel past the dead zone. A box
// whose fine step is no finer than its step still honours Shift: the drag
// goes SCRUB_SLOW_FACTOR times slower, so Shift always means fine. A `snap`
// box keeps whole steps on multiples of `step`; fine steps move freely.
export function scrubbedValue(start, deltaX, fine, options = {}) {
const { step = 1, fineStep = null, snap = false } = options;
if (Math.abs(deltaX) <= SCRUB_DEAD_ZONE) return quantizeScrubValue(start, options);
const finer = fineStep != null && fineStep < step;
const size = fine && finer ? fineStep : step;
const pixels = fine && !finer ? SCRUB_PIXELS_PER_STEP * SCRUB_SLOW_FACTOR : SCRUB_PIXELS_PER_STEP;
const travel = deltaX - Math.sign(deltaX) * SCRUB_DEAD_ZONE;
const steps = Math.round(travel / pixels);
return quantizeScrubValue(steppedValue(start, steps, size, snap && size === step), options);
}
const CSS_ID = "ausboss-scrub-css";
function ensureScrubCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
// Child selectors on purpose: host panels (the transform editor sidebar,
// for one) carry blanket `section input` rules, and these must outrank
// them wherever the control is mounted.
style.textContent = `
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;
background:${T.fill};overflow:hidden;flex:none;color:${T.ink};font:500 12px ${FONT_UI};font-variant-numeric:tabular-nums}
.ausboss-scrub:focus-within{border-color:${T.brand}}
.ausboss-scrub>.ausboss-scrub-input{flex:1 1 auto;min-width:0;width:100%;height:100%;border:none;
padding:0;margin:0;background:transparent;color:inherit;text-align:center;cursor:ew-resize;
user-select:none;font:inherit;outline:none;border-radius:0}
.ausboss-scrub>.ausboss-scrub-input:focus{cursor:text;user-select:text}
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:${T.faint};font:600 10px ${FONT_UI};letter-spacing:.04em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
.ausboss-scrub>.ausboss-scrub-step{flex:none;width:14px;display:flex;flex-direction:column;
border-left:1px solid transparent;background:${T.fillHover}}
.ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent;
color:${T.label};cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${T.brand};background:rgba(255,255,255,.05)}
`;
document.head.append(style);
}
// Stepper chevrons, hand-drawn so no glyph font is trusted to have them.
function chevronSvg(up) {
const points = up ? "1.5,4 4.5,1 7.5,4" : "1.5,1 4.5,4 7.5,1";
return `<svg width="9" height="5" viewBox="0 0 9 5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="${points}"/></svg>`;
}
export function makeScrubInput(options = {}) {
ensureScrubCss();
const opts = {
value: 0, min: -Infinity, max: Infinity,
step: 1, fineStep: null, decimals: 2, minDecimals: null,
width: null, title: "", onChange: null, onSettle: null,
unit: "", unitWidth: 0,
allowEmpty: false, emptyStart: 0, placeholder: "",
snap: false,
...options,
};
const isEmpty = (value) => opts.allowEmpty && (value === null || value === undefined || value === "");
let current = isEmpty(opts.value) ? null : quantizeScrubValue(opts.value, opts);
// Where a scrub or a step starts: the value, or emptyStart while empty.
const base = () => (current === null ? quantizeScrubValue(opts.emptyStart, opts) : current);
// One arrow-key or chevron step; Shift takes the fine step, which never snaps.
const stepped = (direction, fine) => {
const size = fine ? (opts.fineStep ?? opts.step) : opts.step;
return steppedValue(base(), direction, size, opts.snap && size === opts.step);
};
const box = document.createElement("div");
box.className = "ausboss-scrub";
if (opts.width) box.style.width = `${opts.width}px`;
const input = document.createElement("input");
input.className = "ausboss-scrub-input";
input.type = "text";
input.inputMode = "decimal";
input.readOnly = true;
if (opts.placeholder) input.placeholder = opts.placeholder;
if (opts.title) input.title = `${opts.title} Drag to scrub, click to type a number or a sum (1024*1.5), arrows to step; Shift = fine.`;
const format = (value) => formatScrubValue(value, opts);
input.value = format(current);
const commit = (value) => {
const next = isEmpty(value) ? null : quantizeScrubValue(value, opts);
const changed = next !== current;
current = next;
input.value = format(current);
if (changed) opts.onChange?.(current);
return current;
};
const settle = () => opts.onSettle?.();
// Scrub gesture: capture on the input, dead zone keeps clicks clicks.
let drag = null;
input.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !input.readOnly) return;
drag = { x: event.clientX, y: event.clientY, start: base(), scrubbed: false };
input.setPointerCapture(event.pointerId);
event.preventDefault();
});
input.addEventListener("pointermove", (event) => {
if (!drag) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
if (!drag.scrubbed && isScrubGesture(dx, dy)) { drag.scrubbed = true; drag.fine = event.shiftKey; }
if (!drag.scrubbed) return;
// Pressing or letting go of Shift mid-drag carries on from here instead
// of re-reading the whole drag at the other speed.
if (event.shiftKey !== drag.fine) {
drag.start = base(); drag.x = event.clientX - Math.sign(dx || 1) * (SCRUB_DEAD_ZONE + 1); drag.fine = event.shiftKey;
}
commit(scrubbedValue(drag.start, event.clientX - drag.x, event.shiftKey, opts));
});
const endDrag = (event) => {
if (!drag) return;
try { input.releasePointerCapture(event.pointerId); } catch { /* mouse fallback */ }
const wasClick = !drag.scrubbed;
const scrubbed = drag.scrubbed;
drag = null;
if (wasClick) {
input.readOnly = false;
input.focus();
input.select();
// The graph canvas grabs focus during the click sequence on some
// frontends, which silently blurs the input before a keystroke can
// land. One re-assertion after the click settles wins that race
// without starting a focus war.
requestAnimationFrame(() => {
if (document.activeElement !== input) {
input.readOnly = false;
input.focus();
input.select();
}
});
} else if (scrubbed) {
settle();
}
};
input.addEventListener("pointerup", endDrag);
input.addEventListener("pointercancel", endDrag);
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") input.blur();
else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit(stepped(event.key === "ArrowUp" ? 1 : -1, event.shiftKey));
input.select();
settle();
}
});
// Typed values commit on blur/Enter: a number, or a sum such as 1024*1.5.
// Unparseable text falls back to the last good value rather than guessing.
input.addEventListener("blur", () => {
if (!input.readOnly) {
if (opts.allowEmpty && input.value.trim() === "") {
if (current !== null) { commit(null); settle(); }
} else {
const number = Number(input.value);
const typed = Number.isFinite(number) ? number : sumValue(input.value);
if (typed !== null && commit(typed) !== undefined) settle();
}
input.readOnly = true;
}
input.value = format(current);
});
const steppers = document.createElement("div");
steppers.className = "ausboss-scrub-step";
for (const direction of [1, -1]) {
const button = document.createElement("button");
button.type = "button";
button.tabIndex = -1;
button.title = `Step ${direction > 0 ? "up" : "down"}; Shift = fine.`;
button.innerHTML = chevronSvg(direction > 0);
button.addEventListener("click", (event) => {
commit(stepped(direction, event.shiftKey));
settle();
});
steppers.append(button);
}
if (opts.unit || opts.unitWidth > 0) {
// The unit rides inside the box, ahead of the chevrons. A `unitWidth`
// reserves that slot at a fixed width whether or not this field has a
// unit, so in a column of fields every number is centred on the same
// axis - a "px" here and nothing there no longer nudges one of them.
const unit = document.createElement("span");
unit.className = "ausboss-scrub-unit";
unit.textContent = opts.unit;
if (opts.unitWidth > 0) {
unit.style.width = `${opts.unitWidth}px`;
unit.style.padding = "0";
}
box.append(input, unit, steppers);
} else {
box.append(input, steppers);
}
// Let another element scrub this value: the label beside the box, or the
// box itself where its unit is. A drag across it changes the value as a
// drag on the number does. A click there does nothing, so a click on the
// number is still the one way to type.
const handle = (element) => {
let grip = null;
element.style.cursor = "ew-resize";
element.style.touchAction = "none";
element.addEventListener("pointerdown", (event) => {
// Only a press on the element's own face: the number and the arrows
// inside a box keep their own gestures.
if (event.button !== 0 || event.target !== element || !input.readOnly) return;
grip = { x: event.clientX, y: event.clientY, start: base(), scrubbed: false, fine: event.shiftKey };
element.setPointerCapture(event.pointerId);
event.preventDefault();
});
element.addEventListener("pointermove", (event) => {
if (!grip) return;
const dx = event.clientX - grip.x;
if (!grip.scrubbed && isScrubGesture(dx, event.clientY - grip.y)) { grip.scrubbed = true; grip.fine = event.shiftKey; }
if (!grip.scrubbed) return;
if (event.shiftKey !== grip.fine) {
grip.start = base(); grip.x = event.clientX - Math.sign(dx || 1) * (SCRUB_DEAD_ZONE + 1); grip.fine = event.shiftKey;
}
commit(scrubbedValue(grip.start, event.clientX - grip.x, event.shiftKey, opts));
});
const release = (event) => {
if (!grip) return;
try { element.releasePointerCapture(event.pointerId); } catch { /* mouse fallback */ }
const scrubbed = grip.scrubbed;
grip = null;
if (scrubbed) settle();
};
element.addEventListener("pointerup", release);
element.addEventListener("pointercancel", release);
return element;
};
return {
root: box,
input,
handle,
get: () => current,
set: (value) => {
current = isEmpty(value) ? null : quantizeScrubValue(value, opts);
if (input.readOnly) input.value = format(current);
},
// For a value whose valid steps change with another setting (a frame
// count under a snap rule steps by 8, not 1).
setStep: (step, fineStep = null) => { opts.step = step; opts.fineStep = fineStep; },
// For a value whose limits come from somewhere else (a number box takes
// over the range of the input it is wired to). Only the keys given
// change. What is shown is held inside the new range; the caller decides
// whether the stored value follows.
setRange: (range = {}) => {
for (const key of ["min", "max", "step", "fineStep", "decimals", "minDecimals"]) if (range[key] !== undefined) opts[key] = range[key];
if (current !== null) current = quantizeScrubValue(current, opts);
if (input.readOnly) input.value = format(current);
},
};
}