Show number fields with three decimals and snap dragging to the step
Number fields derived their display precision from `step`, so `step: 1` (X/Y/Z, width, height, font size) rendered a stored 100.234 as "100" — and because `onChange` parses the text, touching the field wrote the rounded value back into the project. `formatNumber` now renders every number with three decimals, independent of `step`, which is left to control the scrub and arrow-key increment only. Dragging now snaps to the increment's grid (`snapToStep`), so a scrub at `step: 1` always lands on whole numbers even from a fractional start, while Alt (x0.1) and the 0.1-step fields keep their precision. Typed values and arrow keys are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
15ffcd5739
commit
6168df41f8
6 files changed
+104
-21
No files matched your search
+1
-1
@@ -3,7 +3,7 @@ export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack
|
||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
|
||||
export { clamp, centreY, zoomFactor } from './math';
|
||||
export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
|
||||
export { Label } from './widgets/Label';
|
||||
export { Button, type ButtonOptions } from './widgets/Button';
|
||||
export { Dropdown } from './widgets/Dropdown';
|
||||
|
||||
@@ -8,6 +8,33 @@ export function centreY(containerHeight: number, itemHeight: number): number {
|
||||
return Math.round((containerHeight - itemHeight) / 2);
|
||||
}
|
||||
|
||||
/** Decimal places every number field shows. */
|
||||
export const NUMBER_DECIMALS = 3;
|
||||
|
||||
/**
|
||||
* Display text for a number. Always shows `decimals` places, so a value can never *look* like
|
||||
* a whole number when it is not: precision used to follow the field's step, which hid the
|
||||
* decimals that dragging in the viewport or a graph node had put into the value.
|
||||
* A value that rounds to zero never carries a minus sign.
|
||||
*/
|
||||
export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string {
|
||||
const zero = (0).toFixed(decimals);
|
||||
const text = value.toFixed(decimals);
|
||||
return text === '-' + zero ? zero : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds `value` onto the grid of `stepSize`, so scrubbing a field lands on clean values:
|
||||
* with step 1 on whole numbers, with step 0.1 on tenths. Needed because neither input is
|
||||
* whole – the pointer delta comes in fractional device pixels (a 150 % display yields
|
||||
* thirds) and the starting value may already be fractional from a viewport drag.
|
||||
* The `toFixed` pass removes binary-float noise (0.1 × 3 = 0.30000000000000004).
|
||||
*/
|
||||
export function snapToStep(value: number, stepSize: number): number {
|
||||
if (!(stepSize > 0) || !Number.isFinite(value)) return value;
|
||||
return Number((Math.round(value / stepSize) * stepSize).toFixed(NUMBER_DECIMALS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom multiplier for one wheel event. Exponential, so scrolling up and back down by the same
|
||||
* amount lands exactly where it started. The caller clamps the result into its own range –
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { clamp } from '../math';
|
||||
import { clamp, formatNumber, snapToStep } from '../math';
|
||||
import { ui } from '../UIContext';
|
||||
import { TextField, type TextFieldOptions } from './TextField';
|
||||
|
||||
@@ -7,8 +7,8 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
|
||||
min?: number;
|
||||
max?: number;
|
||||
/**
|
||||
* Increment per scrubbed pixel and per arrow key press. Its magnitude also sets the display
|
||||
* precision: 0.1 shows one decimal, 1 shows none.
|
||||
* Increment per scrubbed pixel and per arrow key press, and the grid a scrub snaps to.
|
||||
* Does not affect the display, which always shows `NUMBER_DECIMALS` decimals.
|
||||
*/
|
||||
step?: number;
|
||||
/** Fires continuously while scrubbing or typing a valid number. */
|
||||
@@ -22,14 +22,16 @@ const SCRUB_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1),
|
||||
* arrow keys step. Values are clamped to min/max and formatted with the step's precision.
|
||||
* arrow keys step. A scrub snaps to the grid of its increment – a step-1 field therefore
|
||||
* always ends on a whole number, even when it started fractional. Values are clamped to
|
||||
* min/max and always shown with `NUMBER_DECIMALS` decimals, so the text never hides that
|
||||
* the value is fractional.
|
||||
*/
|
||||
export class NumberField extends TextField {
|
||||
private numeric: number;
|
||||
private readonly min: number;
|
||||
private readonly max: number;
|
||||
private readonly step: number;
|
||||
private readonly decimals: number;
|
||||
private scrub: { startX: number; startValue: number; moved: boolean } | null = null;
|
||||
onNumberInput?: (value: number) => void;
|
||||
onNumberChange?: (value: number) => void;
|
||||
@@ -39,11 +41,10 @@ export class NumberField extends TextField {
|
||||
this.min = options.min ?? -Infinity;
|
||||
this.max = options.max ?? Infinity;
|
||||
this.step = options.step ?? 1;
|
||||
this.decimals = clamp(Math.ceil(-Math.log10(this.step)), 0, 6);
|
||||
this.onNumberInput = options.onInput;
|
||||
this.onNumberChange = options.onChange;
|
||||
this.numeric = this.clampValue(value);
|
||||
this.valueText = this.format(this.numeric);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.cursor = 'ew-resize';
|
||||
|
||||
this.onInput = (text) => {
|
||||
@@ -56,7 +57,7 @@ export class NumberField extends TextField {
|
||||
this.onChange = (text) => {
|
||||
const n = Number(text.trim());
|
||||
this.numeric = this.clampValue(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric);
|
||||
this.valueText = this.format(this.numeric);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.invalidate();
|
||||
this.onNumberChange?.(this.numeric);
|
||||
};
|
||||
@@ -66,8 +67,8 @@ export class NumberField extends TextField {
|
||||
const dx = e.global.x - this.scrub.startX;
|
||||
if (!this.scrub.moved && Math.abs(dx) < SCRUB_THRESHOLD) return;
|
||||
this.scrub.moved = true;
|
||||
const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
|
||||
this.setNumber(this.scrub.startValue + dx * this.step * factor);
|
||||
const increment = this.step * (e.shiftKey ? 10 : e.altKey ? 0.1 : 1);
|
||||
this.setNumber(snapToStep(this.scrub.startValue + dx * increment, increment));
|
||||
this.onNumberInput?.(this.numeric);
|
||||
});
|
||||
this.onPointerRelease(() => {
|
||||
@@ -93,7 +94,7 @@ export class NumberField extends TextField {
|
||||
setNumber(value: number): void {
|
||||
this.numeric = this.clampValue(value);
|
||||
if (!this.isEditing) {
|
||||
this.valueText = this.format(this.numeric);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
@@ -113,7 +114,7 @@ export class NumberField extends TextField {
|
||||
const dir = event.key === 'ArrowUp' ? 1 : -1;
|
||||
const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||||
this.numeric = this.clampValue(this.numeric + dir * this.step * factor);
|
||||
this.valueText = this.format(this.numeric);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
ui().keyboard.setValue(this.valueText, 0, this.valueText.length);
|
||||
this.selectAll();
|
||||
this.onNumberInput?.(this.numeric);
|
||||
@@ -129,8 +130,4 @@ export class NumberField extends TextField {
|
||||
if (!Number.isFinite(value)) return this.numeric ?? 0;
|
||||
return clamp(value, this.min, this.max);
|
||||
}
|
||||
|
||||
private format(value: number): string {
|
||||
return String(Number(value.toFixed(this.decimals)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user