feat(CAD): 337-1 가-1 옵션 틀 — 물음 꼴 · 친 글 사다리 한 곳 · 명령 끝 → 쉼 · 순서 엔진 v2 · 키보드 흠
- 새 src/commands/prompt.ts — PromptSpec · renderPrompt(키워드 물음은 「또는」 없음) · matchOption(initget 줄임) · 세션 기억 · OPT · SELECT_OBJECTS - 새 inputController/prompt-input.ts — routeTyped 사다리(점 · 옵션 묻는 중 명령 이름 거절 · 식 · 단위 거절 · 투명 ') · 알림 줄 · 덧물음 · cancelActiveCommand · undoCommand - 새 tools/factories/end-command.ts — final(END) 갈고리 → 같은 틱 선택 도구 · state.ts 갈고리 · LASTPOINT - sequence-tool v2 — ROUTE · id · prompt · when · next · options · keyword · loopFrom · escCommits · pickfirst · 확정 뒤 기본 끝 · Esc 늘 끝 - 흠 — N1 ②③ · N5 · N6 · N11 · N18(앞글자 실행 없앰) · H1(Ctrl · Alt 글자 안 쌓음 · Ctrl+C · X · Shift+C · Shift+V · 1) · 우클릭 = 쉴 때 반복 · SE 뺌 · ED 더함 - 시험 — cad-drive 도우미 · prompt-frame · prompt-contract · 옛 시험 손질(keyboard-chain · ime-sink · command-catalog · input-prefs-cad · eraser 녹화) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
{"id": "MTEXT", "label": "여러 줄 문자", "aliases": ["T", "MT"]},
|
||||
{"id": "TEXT", "label": "단일 행 문자", "aliases": ["DT"]},
|
||||
{"id": "STYLE", "label": "문자 스타일", "aliases": ["ST"]},
|
||||
{"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["TEDIT"]},
|
||||
{"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["ED", "TEDIT"]},
|
||||
{"id": "FIND", "label": "찾기·대치", "aliases": []},
|
||||
{"id": "DIM", "label": "치수", "aliases": []},
|
||||
{"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]},
|
||||
@@ -136,7 +136,7 @@
|
||||
{"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]},
|
||||
{"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]},
|
||||
{"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]},
|
||||
{"id": "SELECT", "label": "선택", "aliases": ["SE"]},
|
||||
{"id": "SELECT", "label": "선택", "aliases": []},
|
||||
{"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]},
|
||||
{"id": "ZOOMIN", "label": "확대", "aliases": []},
|
||||
{"id": "ZOOMOUT", "label": "축소", "aliases": []},
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TEXT_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'TEXTEDIT',
|
||||
label: '문자 편집',
|
||||
aliases: ['TEDIT'],
|
||||
aliases: ['ED', 'TEDIT'],
|
||||
glyph: '✎',
|
||||
hint: '기존 문자의 내용을 고친다',
|
||||
tool: Tool.TEXTEDIT,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { requestDrawingExport, requestHostSave } from '../integration/aislo-draw
|
||||
import { runSendSelectionTable } from '../integration/aislo-table-export';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
import { redoCommand, undoCommand } from '../inputController/prompt-input';
|
||||
import { isHostSave } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
|
||||
export const FILE_COMMANDS: CadCommand[] = [
|
||||
@@ -102,7 +103,7 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
aliases: ['U'],
|
||||
glyph: '↶',
|
||||
run: () => {
|
||||
undo();
|
||||
undoCommand(); // 끊고 뒤 되돌림 — Ctrl+Z 와 한 길
|
||||
return '실행 취소';
|
||||
},
|
||||
},
|
||||
@@ -111,7 +112,7 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
label: '다시 실행',
|
||||
glyph: '↷',
|
||||
run: () => {
|
||||
redo();
|
||||
redoCommand();
|
||||
return '다시 실행';
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,7 +32,6 @@ export const VIEW_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'SELECT',
|
||||
label: '선택',
|
||||
aliases: ['SE'],
|
||||
glyph: '↖',
|
||||
hint: '객체를 클릭하거나 선택 사각형으로 고른다',
|
||||
tool: Tool.SELECT,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 물음 꼴(337 설계 2-3 · 2-4 · 2-9) — 오토캐드 명령행 한 줄 「글 또는 [이름(키)/…] <기본>:」 ·
|
||||
* 옵션 맞추기(initget 줄임 규칙) · 세션 기억(앞에 쓴 값이 다음 <기본>).
|
||||
* 도구는 상태 `meta: { kind, prompt }` 로 물음을 선언하고 `OPTION.<key>` 사건으로 옵션을 받는다.
|
||||
* 이 파일은 상태(state.ts)를 읽지 않음 — 기억은 탭 수명 Map 하나.
|
||||
*/
|
||||
import type { StepKind } from '../tools/tool.types';
|
||||
|
||||
export type XY = { x: number; y: number };
|
||||
|
||||
/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.<key>` · 맞추기는 kw · en */
|
||||
export interface OptionSpec {
|
||||
key: string;
|
||||
kw: string;
|
||||
en: string;
|
||||
label: string;
|
||||
hidden?: true;
|
||||
}
|
||||
|
||||
export type DefaultSpec =
|
||||
| { kind: 'value'; remember: string; initial: number | XY | null; format?: 'len' | 'int' | 'ang' }
|
||||
/** <변위> · <전체(T)>(label 로 바꿈) — 빈 Enter = `OPTION.<key>` */
|
||||
| { kind: 'option'; key: string; label?: string }
|
||||
/** <첫 번째 점을 변위로 사용> · <종료> · <객체 선택> — 빈 Enter = `ENTER` */
|
||||
| { kind: 'enter'; label: string };
|
||||
|
||||
export interface PromptSpec {
|
||||
/** '다음 점 지정' — ' 또는 [..]' · ' <..>' · ':' 는 renderPrompt 가 붙임 */
|
||||
text: string;
|
||||
/** 넣은 옵션(숨김 포함) */
|
||||
options?: OptionSpec[];
|
||||
/** 오토캐드엔 있고 우리엔 없음 — 치면 안내 · 안 그림 */
|
||||
absent?: OptionSpec[];
|
||||
default?: DefaultSpec;
|
||||
number?: { int?: true; min?: number; max?: number; positive?: true };
|
||||
/** 거절 글 바꿈(없으면 물음 종류 기본 글) */
|
||||
reject?: string;
|
||||
/** ↑↓ 최근 값 묶음(없으면 text) */
|
||||
history?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 도구마다 context 꼴이 다름
|
||||
export type PromptLike = PromptSpec | ((context: any) => PromptSpec);
|
||||
|
||||
/** 상태 meta 꼴 — `instructions` 는 옛 꼴(전환 중) */
|
||||
export interface PromptMeta {
|
||||
kind?: StepKind;
|
||||
prompt?: PromptLike;
|
||||
instructions?: string;
|
||||
/** 옛 꼴 — 점 물음이 받는 옵션 글자(대문자) */
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
/** 함수 꼴 물음을 지금 context 로 풂 */
|
||||
export const resolvePrompt = (prompt: PromptLike | undefined, context: unknown): PromptSpec | null =>
|
||||
prompt ? (typeof prompt === 'function' ? prompt(context) : prompt) : null;
|
||||
|
||||
// ---- 세션 기억(설계 2-9) ----
|
||||
|
||||
const memory = new Map<string, unknown>();
|
||||
let lastPicked: string[] = [];
|
||||
let lastPointValue: XY | null = null;
|
||||
|
||||
export const remember = (key: string, value: unknown): void => {
|
||||
memory.set(key, value);
|
||||
};
|
||||
export const recall = <T = unknown>(key: string): T | undefined => memory.get(key) as T | undefined;
|
||||
/** 고르기 이전(P) — 명령이 고른 것을 쓸 때 */
|
||||
export const rememberSelection = (ids: string[]): void => {
|
||||
lastPicked = [...ids];
|
||||
};
|
||||
export const previousSelection = (): string[] => lastPicked;
|
||||
/** 마지막 점(LASTPOINT) — `@` 의 기준 */
|
||||
export const rememberPoint = (point: XY): void => {
|
||||
lastPointValue = { x: point.x, y: point.y };
|
||||
};
|
||||
export const lastPoint = (): XY | null => lastPointValue;
|
||||
|
||||
// ---- 그림(설계 2-3) ----
|
||||
|
||||
const isXY = (value: unknown): value is XY =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as XY).x === 'number' &&
|
||||
typeof (value as XY).y === 'number';
|
||||
|
||||
/** 기본 값 — 기억 우선 · 없으면 처음 값 */
|
||||
export function defaultValue(spec: DefaultSpec & { kind: 'value' }): number | XY | null {
|
||||
const remembered = recall<number | XY>(spec.remember);
|
||||
return remembered ?? spec.initial;
|
||||
}
|
||||
|
||||
const formatNumber = (value: number, format: 'len' | 'int' | 'ang' = 'len'): string => {
|
||||
if (format === 'int') return String(Math.round(value));
|
||||
if (format === 'ang') return String(Number(value.toFixed(4)));
|
||||
return value.toFixed(4); // LUPREC 4
|
||||
};
|
||||
|
||||
function renderDefault(prompt: PromptSpec): string | null {
|
||||
const spec = prompt.default;
|
||||
if (!spec) return null;
|
||||
if (spec.kind === 'enter') return spec.label;
|
||||
if (spec.kind === 'option') {
|
||||
return spec.label ?? prompt.options?.find((option) => option.key === spec.key)?.label ?? null;
|
||||
}
|
||||
const value = defaultValue(spec);
|
||||
if (value === null) return null;
|
||||
if (isXY(value)) return `${value.x.toFixed(4)}, ${value.y.toFixed(4)}`;
|
||||
return formatNumber(value, spec.format);
|
||||
}
|
||||
|
||||
/** 「다음 점 지정 또는 [닫기(C)/명령 취소(U)] <10.0000>:」 · 키워드 물음은 「또는」 없이 「옵션 입력 [..] <I>:」 */
|
||||
export function renderPrompt(prompt: PromptSpec, kind?: StepKind): string {
|
||||
const shown = (prompt.options ?? []).filter((option) => !option.hidden);
|
||||
const joiner = kind === 'keyword' ? ' ' : ' 또는 ';
|
||||
const options = shown.length
|
||||
? `${joiner}[${shown.map((option) => `${option.label}(${option.kw})`).join('/')}]`
|
||||
: '';
|
||||
const fallback = renderDefault(prompt);
|
||||
return `${prompt.text}${options}${fallback === null ? '' : ` <${fallback}>`}:`;
|
||||
}
|
||||
|
||||
// ---- 옵션 맞추기(설계 2-4) ----
|
||||
|
||||
/** 친 글 → 맞추기 꼴 — 빈칸 뗌 · 대문자 · 앞 `_` 하나 뗌 */
|
||||
export const normalizeKeyword = (text: string): string => text.trim().toUpperCase().replace(/^_/, '');
|
||||
|
||||
const matches = (text: string, option: OptionSpec): boolean =>
|
||||
text === option.kw || text === option.en || (option.en.startsWith(text) && text.startsWith(option.kw));
|
||||
|
||||
export type OptionMatch = { option: OptionSpec } | { absent: OptionSpec } | null;
|
||||
|
||||
/** 친 글이 맞는 옵션 — kw 딱 맞음 우선 · 숨김도 · 없는 옵션(absent)은 안내용 */
|
||||
export function matchOption(text: string, prompt: PromptSpec | null | undefined): OptionMatch {
|
||||
if (!prompt) return null;
|
||||
const typed = normalizeKeyword(text);
|
||||
if (!typed) return null;
|
||||
const pick = (list: OptionSpec[] | undefined) => {
|
||||
const hits = (list ?? []).filter((option) => matches(typed, option));
|
||||
return hits.find((option) => option.kw === typed) ?? hits[0];
|
||||
};
|
||||
const option = pick(prompt.options);
|
||||
if (option) return { option };
|
||||
const absent = pick(prompt.absent);
|
||||
return absent ? { absent } : null;
|
||||
}
|
||||
|
||||
// ---- 공용 옵션 ----
|
||||
|
||||
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en,
|
||||
label,
|
||||
});
|
||||
const hidden = (option: OptionSpec): OptionSpec => ({ ...option, hidden: true });
|
||||
|
||||
/** 여러 명령이 같이 쓰는 옵션 — 글자가 다른 명령(종료(X) 등)은 그 도구가 따로 만듦 */
|
||||
export const OPT = {
|
||||
UNDO: opt('UNDO', 'U', '명령 취소'),
|
||||
CLOSE: opt('CLOSE', 'C', '닫기'),
|
||||
EXIT: opt('EXIT', 'E', '종료'),
|
||||
MULTIPLE: opt('MULTIPLE', 'M', '다중'),
|
||||
YES: opt('YES', 'Y', '예'),
|
||||
NO: opt('NO', 'N', '아니오'),
|
||||
DISPLACEMENT: opt('DISPLACEMENT', 'D', '변위'),
|
||||
COPY: opt('COPY', 'C', '복사'),
|
||||
REFERENCE: opt('REFERENCE', 'R', '참조'),
|
||||
TEXT: opt('TEXT', 'T', '문자'),
|
||||
ALL: hidden(opt('ALL', 'ALL', '전체')),
|
||||
LAST: hidden(opt('LAST', 'L', '최종')),
|
||||
PREVIOUS: hidden(opt('PREVIOUS', 'P', '이전')),
|
||||
} as const;
|
||||
|
||||
/** 고르기 물음 공용(설계 2-15) — 숨은 옵션 ALL · L · P 의 처리는 선택 도구 몫 */
|
||||
export const SELECT_OBJECTS: PromptSpec = {
|
||||
text: '객체 선택',
|
||||
options: [OPT.ALL, OPT.LAST, OPT.PREVIOUS],
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify';
|
||||
import { VIEW_COMMANDS } from './commands.view';
|
||||
import { inputPrefs } from '../../../../A00_Common/input_prefs';
|
||||
import '../tools/factories/end-command'; // 명령 끝 → 쉬는 상태 갈고리
|
||||
|
||||
const ALL_COMMANDS: CadCommand[] = [
|
||||
...DRAW_COMMANDS,
|
||||
@@ -121,15 +122,14 @@ function userAliasCommands(match: (alias: string) => boolean): CadCommand[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식), 없으면 접두사 후보의 첫 항목 */
|
||||
/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식) 딱 맞음만(앞글자 실행 없음 · 후보는 목록) */
|
||||
export function resolveCommandInput(input: string): CadCommand | undefined {
|
||||
const text = input.trim().toUpperCase();
|
||||
const text = input.trim().toUpperCase().replace(/^_/, '');
|
||||
if (!text) return undefined;
|
||||
return (
|
||||
COMMANDS_BY_ID.get(text) ??
|
||||
userAliasCommands((alias) => alias === text)[0] ??
|
||||
COMMANDS_BY_ALIAS.get(text) ??
|
||||
matchCommandPrefixes(text)[0]
|
||||
COMMANDS_BY_ALIAS.get(text)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { releaseOnePoint } from '../inputController/input-angle-lock';
|
||||
import { cancelActiveCommand } from '../inputController/prompt-input';
|
||||
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
|
||||
@@ -15,7 +16,8 @@ const commandHistory: string[] = [];
|
||||
export const getLastCommandId = (): string | null => lastCommandId;
|
||||
export const getCommandHistory = (): string[] => commandHistory;
|
||||
|
||||
function log(line: string) {
|
||||
/** 기록 줄에 한 줄(명령 · 거절 · 알림) */
|
||||
export function logLine(line: string) {
|
||||
commandHistory.push(line);
|
||||
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
|
||||
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
|
||||
@@ -29,12 +31,12 @@ export function runCommand(command: CadCommand): string {
|
||||
// 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정).
|
||||
if (isDrawingReadOnly() && !isViewOnlyCommand(command)) {
|
||||
toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`);
|
||||
log(`${command.id}: 확정 도면 — 실행하지 않음`);
|
||||
logLine(`${command.id}: 확정 도면 — 실행하지 않음`);
|
||||
return '';
|
||||
}
|
||||
if (command.needsSelection && getSelectedEntities().length === 0) {
|
||||
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
|
||||
log(`${command.id}: 선택 없음`);
|
||||
logLine(`${command.id}: 선택 없음`);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -42,15 +44,16 @@ export function runCommand(command: CadCommand): string {
|
||||
releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음
|
||||
|
||||
if (command.machine) {
|
||||
cancelActiveCommand(); // 앞 명령을 끊고 켬(오토캐드 ^C^C) · 즉시형은 도구를 둠
|
||||
setActiveToolActor(new Actor(command.machine));
|
||||
// 도구를 켠 뒤 준비 동작이 필요한 명령(파일 선택 등)은 run()도 함께 부른다
|
||||
command.run?.();
|
||||
log(`${command.id} ${command.label}`);
|
||||
logLine(`${command.id} ${command.label}`);
|
||||
return command.id;
|
||||
}
|
||||
|
||||
const result = command.run?.();
|
||||
log(`${command.id} ${typeof result === 'string' ? result : command.label}`);
|
||||
logLine(`${command.id} ${typeof result === 'string' ? result : command.label}`);
|
||||
return typeof result === 'string' ? result : command.id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user