A reference search over every export and method in src/ found code that only tests still called, or that was never called at all: - RemoveSceneObjectCommand: the product only removes through the multi-object command; the tests now use that one too. - NodeRegistry.requireNodeDef: a second function of the same name in commands/require.ts raises a CommandError instead of throwing, and is the one the product uses. The twin invited the wrong import. - isTextureAsset, listContainers, AssetTabEditor.show, FocusManager.destroy, KeyboardBridge.selectAll and the getters ColorField.value, ListView.selected, NumberField.number. Also: Theme.controlActive now matches the resting control colour, and a new note asks what editing the graph during play would take. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import type { Container } from 'pixi.js';
|
||
import { collectFocusables, type Widget } from './Widget';
|
||
|
||
/**
|
||
* Keyboard focus lives in the canvas, not in the DOM: one widget is focused at a time and keys
|
||
* reach it from a `window` listener. The keyboard bridge input is the only DOM element that ever
|
||
* holds real focus, and its keys are routed separately.
|
||
*/
|
||
export class FocusManager {
|
||
/** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */
|
||
readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = [];
|
||
/** Called after focus moved, so a panel can catch up on work it postponed while being typed in. */
|
||
readonly changeHandlers: (() => void)[] = [];
|
||
private focusedWidget: Widget | null = null;
|
||
private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event);
|
||
|
||
constructor(private readonly root: Container) {
|
||
window.addEventListener('keydown', this.onKeyDown);
|
||
}
|
||
|
||
get focused(): Widget | null {
|
||
return this.focusedWidget;
|
||
}
|
||
|
||
focus(widget: Widget | null): void {
|
||
if (widget === this.focusedWidget) return;
|
||
const previous = this.focusedWidget;
|
||
this.focusedWidget = widget;
|
||
previous?.onBlur();
|
||
widget?.onFocus();
|
||
for (const handler of this.changeHandlers) handler();
|
||
}
|
||
|
||
/** Whether the focused widget is `node` or sits inside it. */
|
||
isWithin(node: Container): boolean {
|
||
for (let current: Container | null = this.focusedWidget; current; current = current.parent) {
|
||
if (current === node) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
blur(): void {
|
||
this.focus(null);
|
||
}
|
||
|
||
/**
|
||
* Blurs before the press is handled, so a text field commits its edit before whatever the
|
||
* click triggers – a selection change, an inspector rebuild – can destroy it.
|
||
*/
|
||
blurIfOutside(target: Container | null): void {
|
||
const w = this.focusedWidget;
|
||
if (!w) return;
|
||
for (let node = target; node; node = node.parent) {
|
||
if (node === w) return;
|
||
}
|
||
this.focus(null);
|
||
}
|
||
|
||
/** Drop focus if the focused widget was removed from the tree or hidden. */
|
||
validate(): void {
|
||
const w = this.focusedWidget;
|
||
if (w && (w.destroyed || !isAttached(w, this.root) || !w.enabled)) this.focus(null);
|
||
}
|
||
|
||
/** Focus the next (or previous) focusable widget in tree order, wrapping around. */
|
||
cycle(backwards: boolean): void {
|
||
const list = collectFocusables(this.root);
|
||
if (list.length === 0) return this.focus(null);
|
||
const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1;
|
||
const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length;
|
||
this.focus(list[next] ?? null);
|
||
}
|
||
|
||
private handleKeyDown(event: KeyboardEvent): void {
|
||
// Keys typed into the keyboard bridge input are forwarded by the bridge itself.
|
||
if ((event.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
||
if (event.key === 'Tab') {
|
||
event.preventDefault();
|
||
this.cycle(event.shiftKey);
|
||
return;
|
||
}
|
||
this.validate();
|
||
if (this.focusedWidget?.onKeyDown(event)) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
for (const handler of this.globalHandlers) {
|
||
if (handler(event)) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function isAttached(node: Container, root: Container): boolean {
|
||
let current: Container | null = node;
|
||
while (current) {
|
||
if (current === root) return true;
|
||
if (!current.visible) return false;
|
||
current = current.parent;
|
||
}
|
||
return false;
|
||
}
|