fix(B07-CAD): 한글 IME 조합 입력 — 문자 단계 숨은 받개 · 조합 중 단축키 건너뜀
- 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선)에서 숨은 input 에 초점 · compositionupdate/end 로 글자 받음 - 조합 중 키(isComposing · 229 · Process)는 단축키 처리에서 건너뜀 · 명령줄도 같음 - 명령 단계는 초점을 풀어 한글 자판이어도 영문 명령 그대로 - 입력 해석 패턴을 input-patterns.ts 로 나눔(700줄 상한) - 검증 창구 entities 에 label · 시나리오 ui_checks/44-8_CAD_한글입력.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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('가나다');
|
||||
});
|
||||
});
|
||||
@@ -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<InputEvent, 'isComposing' | 'data'>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ', {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user