Files
feuerwerk/src/ui/widgets/TextField.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

275 lines
9.2 KiB
TypeScript

import { CanvasTextMetrics, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import type { TextEditTarget } from '../KeyboardBridge';
import { Theme, textStyle, type TextStyleToken } from '../Theme';
import { ui } from '../UIContext';
import { type Size, Widget } from '../Widget';
export interface TextFieldOptions {
placeholder?: string;
style?: TextStyleToken;
width?: number;
/** Fires while typing. */
onInput?: (value: string) => void;
/** Fires on Enter / blur when the value changed since editing started. */
onChange?: (value: string) => void;
}
/**
* Single-line text input drawn in the canvas. Keyboard handling is delegated to the
* KeyboardBridge while the field is focused; this class only mirrors value, selection
* and caret and handles pointer interaction (caret placement, drag selection).
*/
export class TextField extends Widget implements TextEditTarget {
protected readonly bg = new Graphics();
protected readonly selectionShape = new Graphics();
protected readonly caret = new Graphics();
protected readonly textNode: Text;
protected readonly placeholderNode: Text;
protected valueText = '';
protected editing = false;
protected selStart = 0;
protected selEnd = 0;
private originalValue = '';
private scrollX = 0;
private hovered = false;
private caretVisible = true;
private blinkTimer = 0;
private dragSelecting = false;
private readonly innerPad = 6;
placeholder: string;
onInput?: (value: string) => void;
onChange?: (value: string) => void;
constructor(value = '', options: TextFieldOptions = {}) {
super();
this.valueText = value;
this.placeholder = options.placeholder ?? '';
this.onInput = options.onInput;
this.onChange = options.onChange;
this.focusable = true;
this.cursor = 'text';
this.minHeight = Theme.controlHeight;
this.minWidth = options.width ?? 80;
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
const clip = new Graphics();
this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, clip);
this.mask = clip;
this.clipShape = clip;
this.on('pointerover', () => { this.hovered = true; this.invalidate(); });
this.on('pointerout', () => { this.hovered = false; this.invalidate(); });
this.on('pointerdown', (e: FederatedPointerEvent) => this.handlePointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!this.dragSelecting) return;
const index = this.indexAt(this.toLocal(e.global).x);
this.setSelection(this.selStart, index, true);
});
const stop = () => { this.dragSelecting = false; };
this.on('pointerup', stop);
this.on('pointerupoutside', stop);
}
private readonly clipShape: Graphics;
get value(): string {
return this.valueText;
}
/** Programmatic update (from the model). Ignored while the user is editing. */
setValue(value: string): void {
if (this.editing || value === this.valueText) return;
this.valueText = value;
this.invalidate();
}
get isEditing(): boolean {
return this.editing;
}
protected handlePointerDown(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
e.stopPropagation();
const index = this.indexAt(this.toLocal(e.global).x);
if (!this.editing) {
ui().focus.focus(this);
if (e.detail >= 2) this.selectAll();
else this.setSelection(index, index, true);
} else if (e.detail >= 2) {
this.selectAll();
} else {
this.setSelection(index, index, true);
}
this.dragSelecting = true;
}
// --- focus ---------------------------------------------------------------------------
override onFocus(): void {
super.onFocus();
this.startEditing();
}
override onBlur(): void {
super.onBlur();
this.stopEditing(true);
}
protected startEditing(): void {
if (this.editing) return;
this.editing = true;
this.originalValue = this.valueText;
this.selStart = 0;
this.selEnd = this.valueText.length;
const global = this.getGlobalPosition();
ui().keyboard.moveTo(global.x, global.y);
ui().keyboard.attach(this, this.valueText, this.selStart, this.selEnd);
this.startBlink();
this.invalidate();
}
protected stopEditing(commit: boolean): void {
if (!this.editing) return;
this.editing = false;
this.stopBlink();
ui().keyboard.detach(this);
if (commit) {
if (this.valueText !== this.originalValue) this.onChange?.(this.valueText);
} else if (this.valueText !== this.originalValue) {
this.valueText = this.originalValue;
this.onInput?.(this.valueText);
}
this.scrollX = 0;
this.invalidate();
}
selectAll(): void {
this.setSelection(0, this.valueText.length, true);
}
private setSelection(start: number, end: number, pushToBridge: boolean): void {
this.selStart = Math.max(0, Math.min(this.valueText.length, start));
this.selEnd = Math.max(0, Math.min(this.valueText.length, end));
if (pushToBridge && this.editing) {
const lo = Math.min(this.selStart, this.selEnd);
const hi = Math.max(this.selStart, this.selEnd);
ui().keyboard.setValue(this.valueText, lo, hi);
// Keep our anchor/focus order; the DOM only knows lo/hi.
}
this.caretVisible = true;
this.invalidate();
}
// --- TextEditTarget --------------------------------------------------------------------
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void {
const changed = value !== this.valueText;
this.valueText = value;
this.selStart = selectionStart;
this.selEnd = selectionEnd;
this.caretVisible = true;
this.invalidate();
if (changed) this.onInput?.(value);
}
onBridgeKey(event: KeyboardEvent): boolean {
if (event.key === 'Enter') {
ui().focus.blur(); // triggers onBlur → commit
return true;
}
if (event.key === 'Escape') {
this.stopEditing(false);
ui().focus.blur();
return true;
}
if (event.key === 'Tab') {
ui().focus.cycle(event.shiftKey);
return true;
}
return false;
}
onBridgeBlur(): void {
// DOM focus went elsewhere (browser UI, another tab). Commit and release focus.
this.stopEditing(true);
if (ui().focus.focused === this) ui().focus.blur();
}
// --- geometry --------------------------------------------------------------------------
private textWidth(text: string): number {
return CanvasTextMetrics.measureText(text, this.textNode.style).width;
}
/** Character index closest to a local x coordinate. */
protected indexAt(localX: number): number {
const x = localX - this.innerPad + this.scrollX;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i <= this.valueText.length; i++) {
const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x);
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
private startBlink(): void {
this.stopBlink();
this.caretVisible = true;
this.blinkTimer = window.setInterval(() => {
this.caretVisible = !this.caretVisible;
this.invalidate();
}, 530);
}
private stopBlink(): void {
if (this.blinkTimer) window.clearInterval(this.blinkTimer);
this.blinkTimer = 0;
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, Theme.controlHeight) };
}
override redraw(): void {
const c = Theme.colors;
const innerW = this.w - this.innerPad * 2;
this.bg.clear();
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(this.hovered && !this.editing ? c.controlHover : c.control);
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : c.border });
this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff);
this.textNode.text = this.valueText;
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
// Horizontal scroll so the caret stays visible.
const caretX = this.textWidth(this.valueText.slice(0, this.selEnd));
if (!this.editing) this.scrollX = 0;
else if (caretX - this.scrollX > innerW) this.scrollX = caretX - innerW;
else if (caretX - this.scrollX < 0) this.scrollX = caretX;
const textX = this.innerPad - this.scrollX;
const textY = Math.round((this.h - this.textNode.height) / 2);
this.textNode.position.set(Math.round(textX), textY);
this.placeholderNode.position.set(this.innerPad, textY);
this.selectionShape.clear();
this.caret.clear();
if (!this.editing) return;
const lo = Math.min(this.selStart, this.selEnd);
const hi = Math.max(this.selStart, this.selEnd);
if (hi > lo) {
const x1 = textX + this.textWidth(this.valueText.slice(0, lo));
const x2 = textX + this.textWidth(this.valueText.slice(0, hi));
this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 });
}
if (this.caretVisible) {
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);
}
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopEditing(false);
super.destroy(options);
}
}