Files
feuerwerk/src/ui/widgets/NumberField.ts
T
rootandClaude Fable 5.1 5a46124454 Initial commit: Game Maker phase 1
Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript.

- core: asset model (Scene/Camera/Sprite), scene objects with sparse
  overrides, property schema + resolve, command stack (undo/redo),
  serialization and project store interface (16 unit tests)
- ui: custom in-canvas widget toolkit (panels, fields, color picker,
  list/scroll, popup) with a hidden <input> keyboard bridge
- editor: asset/hierarchy panels, schema-driven inspector, toolbar
- viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan
- runtime: world/entity instantiation for play mode, input, camera follow
- api: PHP JSON project store with revision counter (409 on conflict)
- tools: CDP-based e2e smoke test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 10:07:06 +02:00

128 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FederatedPointerEvent } from 'pixi.js';
import { ui } from '../UIContext';
import { TextField, type TextFieldOptions } from './TextField';
export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | 'onChange'> {
min?: number;
max?: number;
/** Increment per pixel while scrubbing and for arrow keys. */
step?: number;
/** Fires continuously while scrubbing or typing a valid number. */
onInput?: (value: number) => void;
/** Fires once when a scrub ends or an edit is committed. */
onChange?: (value: number) => void;
}
/**
* 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.
*/
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; event: FederatedPointerEvent } | null = null;
onNumberInput?: (value: number) => void;
onNumberChange?: (value: number) => void;
constructor(value = 0, options: NumberFieldOptions = {}) {
super('', { placeholder: options.placeholder, style: options.style ?? 'mono', width: options.width });
this.min = options.min ?? -Infinity;
this.max = options.max ?? Infinity;
this.step = options.step ?? 1;
this.decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(this.step))));
this.onNumberInput = options.onInput;
this.onNumberChange = options.onChange;
this.numeric = this.clamp(value);
this.valueText = this.format(this.numeric);
this.cursor = 'ew-resize';
this.onInput = (text) => {
const n = Number(text.trim());
if (text.trim() !== '' && Number.isFinite(n)) {
this.numeric = this.clamp(n);
this.onNumberInput?.(this.numeric);
}
};
this.onChange = (text) => {
const n = Number(text.trim());
this.numeric = this.clamp(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric);
this.valueText = this.format(this.numeric);
this.invalidate();
this.onNumberChange?.(this.numeric);
};
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!this.scrub) return;
const dx = e.global.x - this.scrub.startX;
if (!this.scrub.moved && Math.abs(dx) < 3) return;
this.scrub.moved = true;
const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
this.setNumber(this.scrub.startValue + dx * this.step * factor);
this.onNumberInput?.(this.numeric);
});
const finish = () => {
const scrub = this.scrub;
this.scrub = null;
if (!scrub) return;
if (scrub.moved) {
this.onNumberChange?.(this.numeric);
} else {
// A plain click: enter text editing with everything selected.
ui().focus.focus(this);
this.selectAll();
}
};
this.on('pointerup', finish);
this.on('pointerupoutside', finish);
}
get number(): number {
return this.numeric;
}
setNumber(value: number): void {
this.numeric = this.clamp(value);
if (!this.isEditing) {
this.valueText = this.format(this.numeric);
this.invalidate();
}
}
protected override handlePointerDown(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
if (this.isEditing) {
super.handlePointerDown(e);
return;
}
e.stopPropagation();
this.scrub = { startX: e.global.x, startValue: this.numeric, moved: false, event: e };
}
override onBridgeKey(event: KeyboardEvent): boolean {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const dir = event.key === 'ArrowUp' ? 1 : -1;
const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
this.numeric = this.clamp(this.numeric + dir * this.step * factor);
this.valueText = this.format(this.numeric);
ui().keyboard.setValue(this.valueText, 0, this.valueText.length);
this.selectAll();
this.onNumberInput?.(this.numeric);
return true;
}
return super.onBridgeKey(event);
}
private clamp(value: number): number {
if (!Number.isFinite(value)) return this.numeric ?? 0;
return Math.max(this.min, Math.min(this.max, value));
}
private format(value: number): string {
const rounded = Number(value.toFixed(this.decimals));
return String(rounded);
}
}