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>
275 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
}
|