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>
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
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);
|
||
}
|
||
}
|