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>
105 lines
3.6 KiB
TypeScript
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();
|
|
}
|
|
}
|