Files
ausboss-ComfyUI-AusBoss/js/shared/scrub_input.mjs
T

227 lines
9.5 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 (AGENTS.md: 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();
// Keep in sync with BRAND in shared/index.mjs - importing it would pull
// /scripts/app.js into node:test, and this module's math must stay testable.
const BRAND = "#00b4aa";
// 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;
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;
}
// 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.
export function scrubbedValue(start, deltaX, fine, options = {}) {
const { step = 1, fineStep = null } = options;
if (Math.abs(deltaX) <= SCRUB_DEAD_ZONE) return quantizeScrubValue(start, options);
const size = fine ? (fineStep ?? step) : step;
const travel = deltaX - Math.sign(deltaX) * SCRUB_DEAD_ZONE;
const steps = Math.round(travel / SCRUB_PIXELS_PER_STEP);
return quantizeScrubValue(Number(start) + steps * size, 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 #3a4047;border-radius:5px;
background:#23272c;overflow:hidden;flex:none;color:#d7dde2;font:12px system-ui}
.ausboss-scrub:focus-within{border-color:${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:#5f7674;font-size:9.5px;letter-spacing:.06em;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 #3a4047}
.ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent;
color:#9ba2aa;cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${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,
width: null, title: "", onChange: null, onSettle: null,
unit: "", unitWidth: 0,
...options,
};
let current = quantizeScrubValue(opts.value, opts);
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.title) input.title = `${opts.title} Drag to scrub, click to type, arrows to step; Shift = fine.`;
const format = (value) => value.toFixed(Math.max(0, opts.decimals));
input.value = format(current);
const commit = (value) => {
const next = 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: current, 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;
if (drag.scrubbed) commit(scrubbedValue(drag.start, dx, 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) => {
event.stopPropagation();
if (event.key === "Enter") input.blur();
else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const size = event.shiftKey ? (opts.fineStep ?? opts.step) : opts.step;
commit(current + size * (event.key === "ArrowUp" ? 1 : -1));
input.select();
settle();
}
});
// Typed values commit on blur/Enter; unparseable text falls back to the
// last good value rather than guessing.
input.addEventListener("blur", () => {
if (!input.readOnly) {
const number = Number(input.value);
if (Number.isFinite(number) && commit(number) !== 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) => {
const size = event.shiftKey ? (opts.fineStep ?? opts.step) : opts.step;
commit(current + size * direction);
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);
}
return {
root: box,
input,
get: () => current,
set: (value) => {
current = quantizeScrubValue(value, opts);
if (input.readOnly) input.value = format(current);
},
};
}