/** * 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(); } }