/**
* The one piece of DOM in the application: an invisible `` that holds keyboard focus while
* a canvas text field is edited, so the browser supplies IME, clipboard, selection and the mobile
* keyboard. The canvas field mirrors its value and selection.
*/
export interface TextEditTarget {
/** Called whenever value or selection changed. */
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
onBridgeKey(event: KeyboardEvent): boolean;
/** The bridge lost DOM focus. */
onBridgeBlur(): void;
}
/** Owns the hidden input and relays it to the single attached `TextEditTarget`. */
export class KeyboardBridge {
private readonly input: HTMLInputElement;
private target: TextEditTarget | null = null;
private suppressBlur = false;
constructor() {
const input = document.createElement('input');
input.type = 'text';
input.setAttribute('aria-hidden', 'true');
input.autocomplete = 'off';
input.spellcheck = false;
Object.assign(input.style, {
position: 'fixed',
left: '0',
top: '0',
width: '1px',
height: '1px',
opacity: '0',
border: '0',
padding: '0',
margin: '0',
outline: 'none',
pointerEvents: 'none',
zIndex: '-1',
} satisfies Partial);
document.body.appendChild(input);
this.input = input;
const sync = () =>
this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0);
input.addEventListener('input', sync);
input.addEventListener('keyup', sync);
document.addEventListener('selectionchange', () => {
if (document.activeElement === input) sync();
});
input.addEventListener('keydown', (event) => {
if (!this.target) return;
if (this.target.onBridgeKey(event)) event.preventDefault();
// Let the browser handle editing keys; sync on next tick to catch the result.
queueMicrotask(sync);
});
input.addEventListener('blur', () => {
if (this.suppressBlur) return;
const target = this.target;
this.target = null;
target?.onBridgeBlur();
});
}
/** Mirrors the field's value into the input and grabs DOM focus. */
attach(
target: TextEditTarget,
value: string,
selectionStart = value.length,
selectionEnd = selectionStart,
): void {
this.suppressBlur = true;
this.target = target;
this.input.value = value;
this.input.focus({ preventScroll: true });
this.input.setSelectionRange(selectionStart, selectionEnd);
this.suppressBlur = false;
}
/** Keep the input positioned near the field so mobile browsers scroll sensibly. */
moveTo(x: number, y: number): void {
this.input.style.left = `${Math.max(0, x)}px`;
this.input.style.top = `${Math.max(0, y)}px`;
}
/** Pushes a value the canvas side changed into the input, so the next keystroke edits that text. */
setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
this.input.value = value;
if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd);
}
/** The field learns about it through the usual `onBridgeInput` sync. */
selectAll(): void {
this.input.select();
}
/** Ignored when another field attached meanwhile, so a late blur cannot steal it. */
detach(target: TextEditTarget): void {
if (this.target !== target) return;
this.target = null;
if (document.activeElement === this.input) this.input.blur();
}
}