From 456c11aa5d76f923fd53340400a48e539e730729 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:02:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07-CAD):=20Space=20=3D=20Enter(=EC=98=A4?= =?UTF-8?q?=ED=86=A0=EC=BA=90=EB=93=9C)=20=C2=B7=20=EB=B9=88=20Enter=20?= =?UTF-8?q?=C2=B7=20Space=20=3D=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=EB=B0=98=EB=B3=B5(97-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캔버스 · 명령줄에서 Space(수정키 없음 · 길게 눌러 반복 아님) = Enter · 글 단계(TEXT · MTEXT · 표 칸 · 이름 등)는 띄어쓰기 - 글 단계 판정 = sequence-tool 단계 meta kind 'text'(activeToolWantsFreeText) - 빈 입력 + 선택 도구 대기에서 키보드 Enter · Space = repeatLastCommand · 빈 우클릭은 확정만 - 한글 조합 중 Space · Shift/Ctrl+Space 무시 - 시험 ime-sink: 「L␣」 = LINE · TEXT 띄어쓰기 · 조합 중 · 수정키 · 반복 무시 · 빈 Space · Enter 반복 · 우클릭 반복 안 함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../openwebcad/src/components/CommandLine.tsx | 11 ++- .../src/inputController/ime-sink.test.ts | 92 ++++++++++++++++++- .../src/inputController/ime-sink.ts | 14 +++ .../src/inputController/input-controller.ts | 24 ++++- .../src/tools/factories/sequence-tool.ts | 2 +- 5 files changed, 136 insertions(+), 7 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 86c97e203..9d354a25a 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -2,6 +2,7 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react'; import { describeCommand, matchCommandPrefixes } from '../commands/registry'; import { getCommandHistory, runCommandInput } from '../commands/run-command'; +import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink'; import { getInputController, getLastStateInstructions } from '../state'; export const CommandLine: FC = () => { @@ -18,7 +19,7 @@ export const CommandLine: FC = () => { const value = text.trim(); if (!value) { // 빈 ENTER는 활성 명령의 확정 신호다 (선택 끝내기·직전 명령 반복) - getInputController().handleEnterKey(); + getInputController().handleEnterKey(true); return; } // 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다 @@ -54,6 +55,14 @@ export const CommandLine: FC = () => { onChange={(event) => setText(event.target.value)} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 + if (event.key === ' ' && !activeToolWantsFreeText()) { + // Space = Enter(오토캐드) · 글 단계는 띄어쓰기 · 수정키 Space 는 그대로(입력칸 몫) + if (isPlainSpace(event)) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } else if (event.repeat) event.preventDefault(); + return; + } if (event.key === 'Escape') { event.preventDefault(); getInputController().handleEscapeKey(); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts index 1be500de3..22ed47630 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { ImeSink, isImeKey } from './ime-sink.ts'; +import { Actor } from 'xstate'; +import { MouseButton } from '../App.types.ts'; +import { getActiveToolActor, setActiveToolActor } from '../state.ts'; +import { Tool } from '../tools.ts'; +import { createSequenceTool } from '../tools/factories/sequence-tool.ts'; +import { selectToolStateMachine } from '../tools/select-tool.ts'; +import { activeToolWantsFreeText, ImeSink, isImeKey } from './ime-sink.ts'; import { InputController } from './input-controller.ts'; describe('한글 IME 입력', () => { @@ -48,3 +54,87 @@ describe('한글 IME 입력', () => { expect((controller as unknown as { text: string }).text).toBe('가나다'); }); }); + +describe('Space = Enter(오토캐드)', () => { + const press = (controller: InputController, key: string, extra: Partial = {}) => + controller.handleKeyStroke({ + key, + keyCode: 0, + isComposing: false, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + repeat: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...extra, + } as unknown as KeyboardEvent); + const textOf = (controller: InputController) => (controller as unknown as { text: string }).text; + const activeTool = () => getActiveToolActor()?.getSnapshot()?.context?.type; + const selectIdle = () => setActiveToolActor(new Actor(selectToolStateMachine), false); + + it('「L␣」 = LINE 실행', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, ' '); + expect(activeTool()).toBe(Tool.LINE); + expect(textOf(controller)).toBe(''); + }); + + it('조합 중 Space · Shift/Ctrl+Space · 길게 눌러 반복은 무시', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, ' ', { isComposing: true, keyCode: 229 }); + press(controller, ' ', { shiftKey: true }); + press(controller, ' ', { ctrlKey: true }); + press(controller, ' ', { repeat: true }); + expect(textOf(controller)).toBe('L'); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it('글 단계(TEXT 등)에서는 띄어쓰기', () => { + const machine = createSequenceTool({ + tool: Tool.TEXT, + steps: [{ kind: 'text', instructions: '문자를 입력하십시오.' }], + commit: () => {}, + }); + setActiveToolActor(new Actor(machine), false); + expect(activeToolWantsFreeText()).toBe(true); + const controller = new InputController(); + press(controller, 'a'); + press(controller, ' '); + press(controller, 'b'); + expect(textOf(controller)).toBe('A B'); + expect(activeTool()).toBe(Tool.TEXT); + }); + + it('빈 Space · Enter = 마지막 명령 반복 · 빈 우클릭은 반복 안 함', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, 'Enter'); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + expect(activeToolWantsFreeText()).toBe(false); + press(controller, ' '); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + press(controller, 'Enter'); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + controller.handleMouseUp({ + button: MouseButton.Right, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as MouseEvent); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts index e79a4e02b..327220356 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -15,6 +15,20 @@ export function activeToolAcceptsText(): boolean { 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 = ''; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 8e8a27c61..e74c7502c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -14,7 +14,7 @@ import { matchCommandPrefixes, resolveCommandInput, } from '../commands/registry.ts'; -import { runCommand } from '../commands/run-command.ts'; +import { repeatLastCommand, runCommand } from '../commands/run-command.ts'; import { requestHostSave } from '../integration/aislo-drawing-bridge.ts'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts'; @@ -58,7 +58,13 @@ import { type RelativePointInputEvent, type TextInputEvent, } from '../tools/tool.types.ts'; -import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts'; +import { + activeToolAcceptsText, + activeToolWantsFreeText, + ImeSink, + isImeKey, + isPlainSpace, +} from './ime-sink.ts'; import { drawInputField } from './input-field-draw.ts'; import { ABSOLUTE_POINT_REGEXP, @@ -437,8 +443,14 @@ export class InputController { this.handleEscapeKey(); } else if (evt.key === 'Enter') { // User wants to submit the input or submit the action - this.handleEnterKey(); + this.handleEnterKey(true); this.ime.sync(activeToolAcceptsText()); + } else if (evt.key === ' ' && !activeToolWantsFreeText()) { + // Space = Enter(오토캐드) · 글 단계는 아래 띄어쓰기 · 수정키 · 길게 눌러 반복은 무시 + if (isPlainSpace(evt)) { + this.handleEnterKey(true); + this.ime.sync(activeToolAcceptsText()); + } } else if (evt.key === 'ArrowDown') { // Move the screen down getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); @@ -482,7 +494,8 @@ export class InputController { return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand); } - public handleEnterKey() { + /** @param repeatIfIdle 키보드 Enter · Space — 빈 입력 + 선택 도구 대기면 마지막 명령 반복(우클릭은 확정만) */ + public handleEnterKey(repeatIfIdle = false) { // submit the text as input to the active tool and clear the input field const activeToolCanHandleTextInput = activeToolAcceptsText(); @@ -492,6 +505,9 @@ export class InputController { activeTool: getActiveToolActor(), }); const wasSelectTool = this.isSelectToolActive(); + const selectIdle = + getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT; + if (repeatIfIdle && wasSelectTool && selectIdle && repeatLastCommand()) return; // Send the ENTER event to the active tool getActiveToolActor()?.send({ type: ActorEvent.ENTER, diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index 3e5f63854..435f50c37 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -331,7 +331,7 @@ export function createSequenceTool(config: SequenceToolConfig) { } states[STEP_STATE(index)] = { - meta: { instructions: step.instructions }, + meta: { instructions: step.instructions, kind: step.kind }, entry: () => { // 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다 setShouldDrawHelpers(useHelpers && step.kind === 'point');