The four mode buttons become one switch, Slide | A | B | Erase, whose marker slides to the choice. In Erase the brush's two jobs and the picture on top are switches of the same kind, and PSD is a plain off | on. Save moves to the bottom right of the node, larger, with a disk icon, in a row of its own with the name of the files and the PSD choice. The files can be named. A Name field on the node takes letters, digits, spaces, dots, dashes and underscores; the server checks the same rule and refuses anything else, so a folder is never part of a name. A new optional filename input takes a wired name, such as the source picture's file name: its folder and extension are dropped. The save route still names no file to read. Size and Soft scrub from their label and from the unit inside the box as well as from the number (the scrub control gains handle() for this). Workflows saved before load as they were: 60 that use the node were opened (the pack's examples, published workflows, the oldest saved shapes), 72 nodes kept their size, mode and links. The node is one input socket taller.
419 lines
18 KiB
JavaScript
419 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";
|
|
|
|
// 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;
|
|
// 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 #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, 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);
|
|
},
|
|
};
|
|
}
|