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; } /** * Registers a handler for keys nobody consumed and returns the call that takes it away again. * Signing out tears the editor down while the page stays up (#177), so everything it registered * here has to go with it – a handler left behind would keep typing into a dead editor. */ onGlobalKey(handler: (event: KeyboardEvent) => boolean): () => void { return remover(this.globalHandlers, handler); } /** The same for "focus moved", used by panels that postpone work while a field is being typed in. */ onFocusChange(handler: () => void): () => void { return remover(this.changeHandlers, handler); } 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 remover(list: T[], entry: T): () => void { list.push(entry); return () => { const index = list.indexOf(entry); if (index >= 0) list.splice(index, 1); }; } 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; }