Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-16 00:12:14 +09:00
13 changed files with 438 additions and 18 deletions
@@ -6,7 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version';
import { getScreenCanvasDrawController, setEntities, getEntities } from '../state';
import { Tool } from '../tools';
import { selectToolStateMachine } from '../tools/select-tool';
import { zoomExtents } from '../inputController/zoom-prompt';
import { startZoomPrompt } from '../inputController/zoom-prompt';
const zoomBy = (factor: number): string => {
const controller = getScreenCanvasDrawController();
@@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [
label: '범위 줌',
aliases: ['Z'],
glyph: '⛶',
hint: '도면 전체가 보이도록 화면을 맞춘다',
hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)',
run: () => {
zoomExtents(); // 앞 화면 기억(이전(P))
return '도면 전체 보기';
startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길
return '';
},
},
{
@@ -1,6 +1,7 @@
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { redoCommand, undoCommand } from '../inputController/prompt-input';
import { zoomExtents } from '../inputController/zoom-prompt';
import {
getScreenCanvasDrawController,
@@ -18,10 +19,10 @@ export const ViewControls: FC = () => {
<div className="cad-view-controls controls">
{showUndo && (
<>
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
↶
</button>
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
↷
</button>
</>
@@ -29,7 +29,7 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve
: mouse;
}
if (event.type === ActorEvent.MOUSE_CLICK) {
const clickEvent = event as MouseClickEvent & { snapped?: boolean };
const clickEvent = event as MouseClickEvent;
const click = clickEvent.worldMouseLocation;
if (locked) return projectOnLock(startPoint, click);
return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift)
@@ -38,6 +38,7 @@ import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import { isObjectSnap, noteShift, snapAt } from './ortho.ts';
import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts';
import { openSnapMenu } from './snap-menu.ts';
import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
import { attachTouch } from './input-touch.ts';
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
@@ -168,10 +169,14 @@ export class InputController {
public handleMouseUp(evt: MouseEvent) {
if (evt.button === MouseButton.Right) {
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복)
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴
evt.preventDefault();
evt.stopPropagation();
this.handleEnterKey(true);
if (evt.shiftKey && currentPrompt().kind === 'point') {
openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name));
} else {
this.handleEnterKey(true);
}
}
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
@@ -63,6 +63,8 @@ export interface SubPrompt {
onNumber?(n: number): SubResult;
onOption?(key: string): SubResult;
onEnter?(): SubResult;
/** 꼴이 정해지지 않은 글(ZOOM 「2X」) — undefined = 안 먹음 · 사다리 그대로 */
onText?(text: string): SubResult | undefined;
}
/** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */
export type SubResult = 'stay' | 'done' | { point: Point };
@@ -377,6 +379,8 @@ export function routeTyped(raw: string): void {
return;
}
if (text.startsWith("'")) return routeTransparent(text.slice(1));
const own = subPrompt()?.onText?.(text);
if (own) return settle(own);
if (prompt.kind === 'idle') {
// ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만
const command = resolveCommandInput(text);
@@ -6,6 +6,11 @@ import type { AnyActorRef } from 'xstate';
import { getActiveToolActor } from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState } from '../tools/select-tool.ts';
import { setKeywordNotice } from '../tools/select-tool.helpers.ts';
import { notice } from './prompt-input.ts';
// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄
setKeywordNotice(notice);
const isSelect = (actor: AnyActorRef | undefined): boolean =>
actor?.getSnapshot()?.context?.type === Tool.SELECT;
@@ -0,0 +1,29 @@
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
.cad-snap-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
}
.cad-snap-menu > button {
padding: 4px 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.cad-snap-menu > button:hover,
.cad-snap-menu > button:focus-visible {
background: var(--color-surface-hover, rgba(127, 127, 127, 0.15));
}
@@ -0,0 +1,76 @@
/**
* Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`).
* 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만.
*/
import './snap-menu.css';
/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */
export const SNAP_MENU: readonly [string, string][] = [
['FROM', '시작점에서'],
['M2P', '두 점 사이의 중간'],
['END', '끝점'],
['MID', '중간점'],
['INT', '교차점'],
['CEN', '중심'],
['QUA', '사분점'],
['PER', '수직'],
['NOD', '노드'],
['NEA', '근처점'],
['NON', '없음'],
];
let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null;
const onKey = (evt: KeyboardEvent) => {
if (evt.key !== 'Escape') return;
evt.preventDefault();
evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로)
closeSnapMenu();
};
const onOutside = (evt: MouseEvent) => {
if (!current?.element?.contains(evt.target as Node)) closeSnapMenu();
};
export const isSnapMenuOpen = (): boolean => current !== null;
export function closeSnapMenu(): void {
if (!current) return;
current.element?.remove();
current = null;
if (typeof window === 'undefined') return;
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('mousedown', onOutside, true);
}
/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */
export function pickSnapMenu(name: string): void {
const picked = current;
closeSnapMenu();
picked?.onPick(name);
}
/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */
export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void {
closeSnapMenu();
current = { onPick, element: null };
if (typeof document === 'undefined') return;
const menu = document.createElement('div');
menu.className = 'cad-snap-menu controls';
menu.setAttribute('role', 'menu');
for (const [name, label] of SNAP_MENU) {
const item = document.createElement('button');
item.type = 'button';
item.setAttribute('role', 'menuitem');
item.textContent = `${label} (${name})`;
item.addEventListener('click', () => pickSnapMenu(name));
menu.append(item);
}
document.body.append(menu);
// 화면 밖으로 안 나가게
const box = menu.getBoundingClientRect();
menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`;
current.element = menu;
window.addEventListener('keydown', onKey, true);
window.addEventListener('mousedown', onOutside, true);
}
@@ -1,9 +1,12 @@
/**
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위.
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음.
* 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개.
* ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명).
*/
import type { Point } from '@flatten-js/core';
import { Point } from '@flatten-js/core';
import type { OptionSpec, PromptSpec } from '../commands/prompt.ts';
import { getScreenCanvasDrawController } from '../state.ts';
import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts';
type View = { scale: number; offset: Point };
@@ -46,3 +49,110 @@ export function isMiddleDouble(point: { x: number; y: number }, time = Date.now(
lastMiddle = double ? null : { time, x: point.x, y: point.y };
return double;
}
/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */
function setScaleAboutCenter(scale: number): void {
const controller = getScreenCanvasDrawController();
const size = controller.getCanvasSize();
const offset = controller.getScreenOffset();
const old = controller.getScreenScale();
const cx = offset.x + size.x / 2 / old;
const cy = offset.y + size.y / 2 / old;
controller.setScreenScale(scale);
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
}
/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */
export function zoomWindow(a: Point, b: Point): void {
const controller = getScreenCanvasDrawController();
const size = controller.getCanvasSize();
const width = Math.abs(a.x - b.x);
const height = Math.abs(a.y - b.y);
const scale = Math.min(
width > 0 ? size.x / width : Number.POSITIVE_INFINITY,
height > 0 ? size.y / height : Number.POSITIVE_INFINITY
);
if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2;
changeView(() => {
controller.setScreenScale(scale);
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
});
}
const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({
key,
kw,
en: key,
label,
});
/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */
const ZOOM_PROMPT: PromptSpec = {
text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력',
options: [
zoomOption('ALL', 'A', '전체'),
zoomOption('EXTENTS', 'E', '범위'),
zoomOption('PREVIOUS', 'P', '이전'),
zoomOption('WINDOW', 'W', '윈도우'),
],
absent: [
zoomOption('CENTER', 'C', '중심'),
zoomOption('DYNAMIC', 'D', '동적'),
zoomOption('SCALE', 'S', '축척'),
zoomOption('OBJECT', 'O', '객체'),
],
default: { kind: 'enter', label: '실시간' },
};
const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' };
const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' };
const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i;
/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */
export function startZoomPrompt(): void {
let stage: 'main' | 'first' | 'other' = 'main';
let corner: Point | null = null;
const sub: SubPrompt = {
kind: 'point',
get prompt() {
return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER;
},
onPoint(point): SubResult {
if (stage === 'other' && corner) {
zoomWindow(corner, point);
return 'done';
}
corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드)
stage = 'other';
return 'stay';
},
onOption(key): SubResult {
if (key === 'WINDOW') {
stage = 'first';
return 'stay';
}
if (key === 'PREVIOUS') {
if (!zoomPrevious()) notice('앞 화면이 없음.');
return 'done';
}
zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음)
return 'done';
},
onEnter(): SubResult {
if (stage !== 'main') return 'stay';
notice('실시간 줌은 휠로.');
return 'done';
},
onText(text) {
const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null;
if (!match) return undefined;
const factor = Number.parseFloat(match[1]);
if (factor <= 0) return undefined;
const scale = getScreenCanvasDrawController().getScreenScale() * factor;
changeView(() => setScaleAboutCenter(scale));
return 'done';
},
};
pushSubPrompt(sub);
}
@@ -1,10 +1,11 @@
/**
* 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다(뜨거운 그립).
* 오토캐드처럼 누르고-옮기고-누르기 · 끌어 놓기 둘 다(누름 · 뗌 가름은 input-controller) ·
* 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc = 그립 놓음.
* 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc · 종료(X) = 그립 놓음.
*/
import type { Point } from '@flatten-js/core';
import { Actor, assign, createMachine } from 'xstate';
import type { OptionSpec, PromptSpec } from '../commands/prompt';
import type { Entity } from '../entities/Entity';
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { type Grip, applyGrip } from '../helpers/grips';
@@ -20,6 +21,24 @@ import { Tool } from '../tools';
import { selectToolStateMachine } from './select-tool';
import { ActorEvent, type PointInputEvent, type StateEvent, type ToolContext } from './tool.types';
const gripOption = (key: string, kw: string, label: string): OptionSpec => ({
key,
kw,
en: key,
label,
});
/** 오토캐드 그립 신축 줄 — 종료(X)만 · 기준점(B) · 복사(C) · 명령 취소(U) 는 뺌 */
const GRIP_PROMPT: PromptSpec = {
text: '** 신축 ** 신축점 지정',
options: [gripOption('EXIT', 'X', '종료')],
absent: [
gripOption('BASE', 'B', '기준점'),
gripOption('COPY', 'C', '복사'),
gripOption('UNDO', 'U', '명령 취소'),
],
};
let editedEntity: Entity | null = null;
let editedGrip: Grip | null = null;
@@ -64,7 +83,7 @@ export const gripEditToolStateMachine = createMachine(
},
[GripEditState.WAITING_FOR_TARGET_POINT]: {
description: '그립을 옮길 위치를 지정한다',
meta: { instructions: '** 신축 ** 신축점 지정' },
meta: { kind: 'point', prompt: GRIP_PROMPT },
on: {
[ActorEvent.DRAW]: { actions: GripEditAction.DRAW_TEMP_GRIP_EDIT },
[ActorEvent.MOUSE_CLICK]: {
@@ -80,6 +99,7 @@ export const gripEditToolStateMachine = createMachine(
actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL],
},
[ActorEvent.ESC]: { actions: GripEditAction.SWITCH_TO_SELECT_TOOL },
'OPTION.EXIT': { actions: GripEditAction.SWITCH_TO_SELECT_TOOL },
},
},
},
@@ -31,6 +31,7 @@ import {
setSelectedEntityIds,
} from '../state';
import { Tool } from '../tools';
import { previousSelection } from '../commands/prompt';
import { startGripEdit } from './grip-edit-tool';
import type { SelectContext } from './select-tool';
import type { MouseClickEvent } from './tool.types';
@@ -113,6 +114,26 @@ export function combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle
return [...before, ...ids.filter((id) => !before.includes(id))];
}
/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */
let notice: (text: string) => void = () => {};
export const setKeywordNotice = (sink: (text: string) => void): void => {
notice = sink;
};
/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */
export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void {
const pickable = getPickableEntities();
const found =
key === 'ALL'
? pickable.map((entity) => entity.id)
: key === 'LAST'
? pickable.slice(-1).map((entity) => entity.id)
: previousSelection().filter((id) => pickable.some((entity) => entity.id === id));
const ids = combineSelection(expandSelectionWithGroups(found), 'add');
setSelectedEntityIds(ids);
notice(`${found.length}개를 찾음, 총 ${ids.length}개`);
}
/** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */
export const gripsEditable = (): boolean =>
!isViewOnly() &&
@@ -14,6 +14,7 @@ import { assign, createMachine } from 'xstate';
import {
drawTempSelectionRectangle,
handleFirstSelectionPoint,
selectByKeyword,
selectEntitiesInsideRectangle,
} from './select-tool.helpers';
@@ -76,6 +77,10 @@ export const selectToolStateMachine = createMachine(
actions: SelectAction.DELETE_SELECTED_ENTITIES,
target: SelectState.INIT,
},
// 고르기 숨은 키워드(SELECT_OBJECTS) — 전체 · 최종 · 이전
'OPTION.ALL': { actions: () => selectByKeyword('ALL') },
'OPTION.LAST': { actions: () => selectByKeyword('LAST') },
'OPTION.PREVIOUS': { actions: () => selectByKeyword('PREVIOUS') },
},
},
[SelectState.CHECK_SELECTION]: {
+149 -5
View File
@@ -1,7 +1,8 @@
/**
* CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마).
* 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 ·
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂.
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂 ·
* 마-2: 우클릭(쉼 반복 · 명령 중 Enter) · ZOOM(E · A · W · P · 2X · 'Z) · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 X · Esc.
* 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad
*/
@@ -14,6 +15,17 @@ vi.mock("react-toastify", () => ({
import { Actor } from "xstate";
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
import { zoomPrevious } from "../../../B07_DesignDetail/openwebcad/src/inputController/zoom-prompt";
import {
isSnapMenuOpen,
pickSnapMenu,
} from "../../../B07_DesignDetail/openwebcad/src/inputController/snap-menu";
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
import { selectingActor } from "../../../B07_DesignDetail/openwebcad/src/inputController/selecting";
import {
currentNotice,
currentPrompt,
} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
import { rememberSelection } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel";
import {
getActiveToolActor,
@@ -66,6 +78,11 @@ const release = (x: number, y: number, mods?: Mods) => {
move(x, y, mods);
controller.handleMouseUp(ev(x, y, mods));
};
/** 가운데 단추 누름 + 뗌(뗌 = 팬 끝) */
const middle = (x: number, y: number) => {
press(x, y, { button: 1 });
controller.handleMouseUp(ev(x, y, { button: 1 }));
};
const click = (x: number, y: number, mods?: Mods) => {
press(x, y, mods);
release(x, y, mods);
@@ -85,6 +102,8 @@ const key = (k: string) =>
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent);
const rightClick = (shift = false) =>
controller.handleMouseUp(ev(0, 0, { button: 2, shiftKey: shift }));
const typeEnter = (text: string) => {
for (const ch of text) key(ch);
key("Enter");
@@ -184,6 +203,20 @@ describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 ·
expect(toolType()).toBe(Tool.SELECT);
});
it("뜨거운 그립 Esc · 종료(X) = 놓음(안 바뀜) · 물음 줄", () => {
click(50, 0);
click(100, 0);
expect(currentPrompt().line).toBe("** 신축 ** 신축점 지정 또는 [종료(X)]:");
move(150, 90);
key("Escape");
expect(toolType()).toBe(Tool.SELECT);
expectXy(segOf(a).end, 100, 0);
click(100, 0); // 고름은 남음 — 다시 그립
typeEnter("x");
expect(toolType()).toBe(Tool.SELECT);
expectXy(segOf(a).end, 100, 0);
});
it("그립 끌어 놓음 = 놓은 자리", () => {
click(50, 0);
press(100, 0);
@@ -292,17 +325,128 @@ describe("휠", () => {
view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3));
const now = vi.spyOn(Date, "now");
now.mockReturnValue(1000);
press(10, 10, { button: 1 });
middle(10, 10);
now.mockReturnValue(1300);
press(11, 11, { button: 1 });
middle(11, 11);
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
expect(view.getScreenScale()).toBe(3);
expect(zoomPrevious()).toBe(true);
expect(view.getScreenScale()).toBe(1);
now.mockReturnValue(5000);
press(10, 10, { button: 1 });
middle(10, 10);
now.mockReturnValue(5600);
press(10, 10, { button: 1 });
middle(10, 10);
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
});
});
describe("우클릭 · 고르기 키워드(마-2)", () => {
it("우클릭 = 쉴 때 앞 명령 반복 · 명령 중 Enter(고르기 끝)", () => {
typeEnter("DRAWORDER");
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
key("Escape");
expect(toolType()).toBe(Tool.SELECT);
rightClick();
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
expect(selectingActor()).not.toBeNull();
click(50, 0);
rightClick(); // 고르기 끝 → 명령 확정
expect(selectingActor()?.getSnapshot()?.context?.type).toBe(Tool.SELECT);
expect(toolType()).toBe(Tool.SELECT); // 쉼으로
});
it("고르기 ALL · L(최종) · P(이전) — 앞 고름에 더함 · 「n개를 찾음」", () => {
typeEnter("DRAWORDER");
typeEnter("all");
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
expect(currentNotice()).toBe("2개를 찾음, 총 2개");
key("Escape");
setSelectedEntityIds([]);
typeEnter("DRAWORDER");
typeEnter("L");
expect(getSelectedEntityIds()).toEqual([b]);
key("Escape");
setSelectedEntityIds([]);
rememberSelection([a]);
typeEnter("DRAWORDER");
typeEnter("p");
expect(getSelectedEntityIds()).toEqual([a]);
});
});
describe("ZOOM 덧물음 · 스냅 메뉴(마-2)", () => {
const view = () =>
getScreenCanvasDrawController() as unknown as {
zoomToFitScreen: () => void;
setScreenScale: (s: number) => void;
getScreenScale: () => number;
getCanvasSize: () => Xy;
};
it("Z 물음 줄 · E = 범위 · P = 앞 화면 · Enter = 실시간 안내", () => {
view().zoomToFitScreen = vi.fn(() => view().setScreenScale(4));
typeEnter("z");
expect(currentPrompt().line).toBe(
"윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:",
);
typeEnter("e");
expect(view().getScreenScale()).toBe(4);
expect(toolType()).toBe(Tool.SELECT);
typeEnter("Z");
typeEnter("P");
expect(view().getScreenScale()).toBe(1);
typeEnter("Z");
typeEnter("C");
expect(currentNotice()).toBe("「중심(C)」 옵션은 이 CAD 에 없음.");
key("Enter");
expect(currentNotice()).toBe("실시간 줌은 휠로.");
expect(currentPrompt().kind).toBe("idle");
});
it("2X = 배율 두 배(가운데 붙잡음) · W = 두 구석 · 누름 = 바로 윈도우", () => {
typeEnter("Z");
typeEnter("2x");
expect(view().getScreenScale()).toBe(2);
expect(zoomPrevious()).toBe(true); // 배율 · 원점 되돌림
const size = view().getCanvasSize();
typeEnter("Z");
typeEnter("W");
expect(currentPrompt().line).toBe("첫 번째 구석 지정:");
click(0, 0);
expect(currentPrompt().line).toBe("반대 구석 지정:");
click(100, 50);
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 100, size.y / 50), 6);
expect(getSelectedEntityIds()).toEqual([]); // 고르기로 안 샘
zoomPrevious();
typeEnter("Z");
click(0, 0);
click(50, 100);
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 50, size.y / 100), 6);
zoomPrevious();
});
it("'Z 투명 — 선 그리는 중 2X 뒤 선 이어 감", () => {
lineTool();
click(300, 300);
typeEnter("'z");
typeEnter("2X");
expect(view().getScreenScale()).toBe(2);
expect(toolType()).toBe(Tool.LINE);
zoomPrevious();
});
it("Shift+우클릭 = 점 물음에서만 스냅 메뉴 → 한 번 스냅", () => {
rightClick(true); // 쉼 — 메뉴 없음(앞 명령 반복)
expect(isSnapMenuOpen()).toBe(false);
key("Escape"); // 반복된 명령 끝
lineTool();
click(300, 300);
rightClick(true);
expect(isSnapMenuOpen()).toBe(true);
pickSnapMenu("END");
expect(isSnapMenuOpen()).toBe(false);
expect(getSnapOverride()).toBe("END");
click(96, 4);
expectXy(lastSeg().end, 100, 0);
});
});