127 lines
4.2 KiB
TypeScript
127 lines
4.2 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|