* 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
417 lines
18 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|