diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 8a3c73e7e..86c97e203 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -53,6 +53,7 @@ export const CommandLine: FC = () => { value={text} onChange={(event) => setText(event.target.value)} onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 if (event.key === 'Escape') { event.preventDefault(); getInputController().handleEscapeKey(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 06d070803..465047359 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -31,6 +31,8 @@ export function registerCadDebugHook(): void { opacity: entity.opacity ?? null, hidden: isEntityHidden(entity), color: entity.lineColor, + // 글자 객체의 내용 — 한글 입력(IME) 검증이 읽는다 + label: (entity as { getLabel?: () => string }).getLabel?.() ?? null, box: [ Math.round(box.xmin), Math.round(box.ymin), diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts new file mode 100644 index 000000000..1be500de3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ImeSink, isImeKey } from './ime-sink.ts'; +import { InputController } from './input-controller.ts'; + +describe('한글 IME 입력', () => { + it('조합 사건으로 「가나다」 가 들어간다', () => { + let text = ''; + const sink = new ImeSink((data) => { + text += data; + }); + for (const [steps, done] of [ + [['ㄱ', '가'], '가'], + [['ㄴ', '나'], '나'], + [['ㄷ', '다'], '다'], + ] as const) { + for (const step of steps) sink.handleCompositionUpdate(step); + expect(sink.composing).toBe(steps[steps.length - 1]); + sink.handleCompositionEnd(done); + } + expect(text).toBe('가나다'); + expect(sink.composing).toBe(''); + // 조합 중 input 은 compositionend 몫 — 겹쳐 넣지 않음 · 조합 뒤 띄어쓰기는 받음 + sink.handleInput({ isComposing: true, data: '다' }); + sink.handleInput({ isComposing: false, data: ' ' }); + expect(text).toBe('가나다 '); + }); + + it('조합 중 키는 단축키로 먹지 않는다', () => { + expect(isImeKey({ isComposing: true, keyCode: 0, key: 'r' } as KeyboardEvent)).toBe(true); + expect(isImeKey({ isComposing: false, keyCode: 229, key: 'r' } as KeyboardEvent)).toBe(true); + expect(isImeKey({ isComposing: false, keyCode: 0, key: 'Process' } as KeyboardEvent)).toBe( + true + ); + expect(isImeKey({ isComposing: false, keyCode: 76, key: 'l' } as KeyboardEvent)).toBe(false); + + const controller = new InputController(); + const preventDefault = vi.fn(); + controller.handleKeyStroke({ + key: 'Process', + keyCode: 229, + isComposing: true, + target: null, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent); + expect(preventDefault).not.toHaveBeenCalled(); + controller.ime.handleCompositionEnd('가나다'); + expect((controller as unknown as { text: string }).text).toBe('가나다'); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts new file mode 100644 index 000000000..e79a4e02b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -0,0 +1,68 @@ +/** + * 한글 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; +} + +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(); + } + } +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index ae5fcdc79..acec2ed3e 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -65,21 +65,15 @@ import { type RelativePointInputEvent, type TextInputEvent, } from '../tools/tool.types.ts'; - -const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; -/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ -const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; -const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); -const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); -/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ -const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); -const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); - -/** 거리·각도(도)를 x·y 변위로 바꾼다 */ -function polarToPoint(distance: number, degrees: number): Point { - const radians = (degrees * Math.PI) / 180; - return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); -} +import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts'; +import { + ABSOLUTE_POINT_REGEXP, + ABSOLUTE_POLAR_REGEXP, + NUMBER_REGEXP, + polarToPoint, + RELATIVE_POINT_REGEXP, + RELATIVE_POLAR_REGEXP, +} from './input-patterns.ts'; /** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */ const DRAG_SELECT_MIN_PX = 4; @@ -88,11 +82,15 @@ export class InputController { private text = ''; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; + public readonly ime = new ImeSink((data) => { + this.text += data; + }); constructor() { if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { return; // used during unit testing } + this.ime.attach(); // Listen for keystrokes document.addEventListener('keydown', (evt) => { this.handleKeyStroke(evt); @@ -141,10 +139,11 @@ export class InputController { CANVAS_INPUT_FIELD_HEIGHT, CANVAS_INPUT_FIELD_BACKGROUND_COLOR ); - // Draw text in input field - if (this.text) { + this.ime.sync(activeToolAcceptsText()); + // Draw text in input field (조합 중인 한글도 함께) + if (this.text || this.ime.composing) { drawController.drawTextScreen( - this.text, + this.text + this.ime.composing, new Point( screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2 @@ -217,6 +216,7 @@ export class InputController { return; } this.sendMouseClick(evt); + this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로 } } @@ -392,9 +392,11 @@ export class InputController { } public handleKeyStroke(evt: KeyboardEvent) { - if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) { + const target = evt.target as HTMLElement | null; + if (target !== this.ime.element && target?.closest('input, textarea, select')) { return; } + if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김 console.log(`key pressed: ${evt.key}`); if (evt.key === 'F12') { // F12 => open developer tools @@ -472,6 +474,7 @@ export class InputController { } else if (evt.key === 'Enter') { // User wants to submit the input or submit the action this.handleEnterKey(); + this.ime.sync(activeToolAcceptsText()); } else if (evt.key === 'ArrowDown') { // Move the screen down getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); @@ -517,11 +520,7 @@ export class InputController { public handleEnterKey() { // submit the text as input to the active tool and clear the input field - const activeTool = getActiveToolActor(); - const activeToolSnapshot = activeTool?.getSnapshot(); - const activeToolState = activeToolSnapshot?.value; - const activeToolCanHandleTextInput = - !!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT; + const activeToolCanHandleTextInput = activeToolAcceptsText(); if (this.text === '') { console.log('ENTER: ', { diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts new file mode 100644 index 000000000..d2483f459 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts @@ -0,0 +1,17 @@ +/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */ +import { Point } from '@flatten-js/core'; + +export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; +/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ +const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; +export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); +export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); +/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ +export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); +export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); + +/** 거리·각도(도)를 x·y 변위로 바꾼다 */ +export function polarToPoint(distance: number, degrees: number): Point { + const radians = (degrees * Math.PI) / 180; + return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); +} diff --git a/resources/tester/ui_checks/44-8_CAD_한글입력.js b/resources/tester/ui_checks/44-8_CAD_한글입력.js new file mode 100644 index 000000000..98b401aca --- /dev/null +++ b/resources/tester/ui_checks/44-8_CAD_한글입력.js @@ -0,0 +1,66 @@ +/* 44-8 CAD 한글 입력 — orca eval 한 번에 도는 시나리오. + * 시작 화면: http://localhost:5183/b07-cad/index.html (CAD 앱 단독 · 빈 도면 · 저장 안 함) + * 또는 CAD iframe 을 품은 화면(B07 · M02 양식). + * 하는 일: 키보드로 TEXT 명령 → 캔버스 한 점 클릭 → 숨은 받개(.cad-ime-sink) 초점 → + * 조합 사건(keydown Process · compositionstart/update/end)으로 「가나다」 → Enter → 글자 객체 → Ctrl+Z 되돌림. + * 기대: sinkFocused true · imeKeyPrevented 0 · label "가나다" · 명령 끝 뒤 sinkReleased true · + * 되돌린 뒤 textCount = 시작 값. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const w = document.querySelector("iframe")?.contentWindow ?? window; + const d = w.document; + const cad = w.__aisloCad; + const texts = () => cad.entities().filter((e) => e.type === "Text"); + const before = texts().length; + const key = (el, opts) => { + const e = new w.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...opts }); + el.dispatchEvent(e); + return e.defaultPrevented; + }; + for (const k of ["T", "E", "X", "T", "Enter"]) key(d.body, { key: k }); + const canvas = d.querySelector("canvas"); + const r = canvas.getBoundingClientRect(); + const at = { clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, bubbles: true }; + canvas.dispatchEvent(new w.MouseEvent("mouseenter", at)); + canvas.dispatchEvent(new w.MouseEvent("mousemove", at)); + canvas.dispatchEvent(new w.MouseEvent("mousedown", { ...at, button: 0 })); + canvas.dispatchEvent(new w.MouseEvent("mouseup", { ...at, button: 0 })); + canvas.dispatchEvent(new w.MouseEvent("mousemove", { ...at, clientX: at.clientX + 3 })); + await W(300); + const sink = d.querySelector(".cad-ime-sink"); + const sinkFocused = !!sink && d.activeElement === sink; + let imeKeyPrevented = 0; + const comp = (type, data) => + sink.dispatchEvent(new w.CompositionEvent(type, { data, bubbles: true })); + for (const [jamo, syllable] of [ + ["ㄱ", "가"], + ["ㄴ", "나"], + ["ㄷ", "다"], + ]) { + imeKeyPrevented += key(sink, { key: "Process", keyCode: 229 }); + comp("compositionstart", ""); + comp("compositionupdate", jamo); + imeKeyPrevented += key(sink, { key: "Process", keyCode: 229, isComposing: true }); + comp("compositionupdate", syllable); + comp("compositionend", syllable); + } + key(sink, { key: "Enter" }); + await W(100); + const made = texts().slice(before); + const label = made.length ? made[made.length - 1].label : null; + canvas.dispatchEvent(new w.MouseEvent("mousemove", { ...at, clientX: at.clientX + 6 })); + await W(300); + const sinkReleased = d.activeElement !== sink; + key(d.body, { key: "Escape" }); + if (made.length) key(d.body, { key: "z", ctrlKey: true }); + await W(100); + return JSON.stringify({ + sinkFocused, + imeKeyPrevented, + label, + sinkReleased, + textBefore: before, + textAfterUndo: texts().length, + }); +})();