Files
feuerwerk/src/ui/KeyboardBridge.ts
T
rootandClaude Opus 5 4d84231b9f Trim needless comments and fix the picker width
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:52:05 +02:00

105 lines
3.6 KiB
TypeScript

/**
* The one piece of DOM in the application: an invisible `<input>` 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<CSSStyleDeclaration>);
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();
}
}