feat(B07-CAD): Space = Enter(오토캐드) · 빈 Enter · Space = 마지막 명령 반복(97-3)
- 캔버스 · 명령줄에서 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<KeyboardEvent> = {}) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, { kind?: string } | undefined>
|
||||
| undefined;
|
||||
return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text');
|
||||
}
|
||||
|
||||
/** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */
|
||||
export const isPlainSpace = (
|
||||
evt: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' | 'repeat'>
|
||||
): boolean =>
|
||||
evt.key === ' ' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey && !evt.metaKey && !evt.repeat;
|
||||
|
||||
export class ImeSink {
|
||||
/** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */
|
||||
public composing = '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user