/** * 한글 IME 받개 — 캔버스에는 글 칸이 없어 조합(compositionstart/update/end)이 생기지 않는다. * 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선 …)에서만 숨은 input 에 초점을 줘 조합을 받고, * 끝난 글자를 넘긴다. 명령 단계에서는 초점을 풀어 한글 자판이어도 L · REC 같은 영문 명령이 들어간다. */ import { getActiveToolActor } from '../state.ts'; /** 조합 중 키 — 단축키·글자 처리가 먹지 않고 IME 에 넘긴다 */ export const isImeKey = (evt: KeyboardEvent): boolean => evt.isComposing || evt.keyCode === 229 || evt.key === 'Process'; /** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */ export function activeToolAcceptsText(): boolean { const snapshot = getActiveToolActor()?.getSnapshot(); return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT; } /** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */ export function activeToolWantsFreeText(): boolean { const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as | Record | undefined; return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text'); } /** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */ export const isPlainSpace = ( evt: Pick ): boolean => evt.key === ' ' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey && !evt.metaKey && !evt.repeat; export class ImeSink { /** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */ public composing = ''; public element: HTMLInputElement | null = null; constructor(private readonly onCommit: (text: string) => void) {} public attach() { const el = document.createElement('input'); el.className = 'cad-ime-sink'; el.tabIndex = -1; el.autocomplete = 'off'; el.setAttribute('aria-hidden', 'true'); el.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;pointer-events:none'; el.addEventListener('compositionupdate', (evt) => this.handleCompositionUpdate(evt.data)); el.addEventListener('compositionend', (evt) => this.handleCompositionEnd(evt.data)); el.addEventListener('input', (evt) => this.handleInput(evt as InputEvent)); document.body.appendChild(el); this.element = el; } public handleCompositionUpdate(data: string) { this.composing = data ?? ''; } public handleCompositionEnd(data: string) { this.composing = ''; if (this.element) this.element.value = ''; if (data) this.onCommit(data); } /** 조합 직후 IME 가 바로 넣는 글자(띄어쓰기 · 숫자) — 조합 중 input 은 compositionend 가 맡는다 */ public handleInput(evt: Pick) { if (evt.isComposing || !evt.data) return; if (this.element) this.element.value = ''; this.onCommit(evt.data); } /** 문자를 기다리면 받개에 초점 · 아니면 놓음. 다른 입력 칸(명령줄 · 대화상자)의 초점은 뺏지 않는다 */ public sync(wantsText: boolean) { const el = this.element; if (!el) return; const active = document.activeElement; if (wantsText && !active?.closest('input, textarea, select, [contenteditable]')) { el.focus({ preventScroll: true }); } else if (!wantsText && active === el && !this.composing) { el.blur(); } } }