Files
feuerwerk/src/ui/FocusManager.ts
T

127 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(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;
}