Files
Aislo/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts
T
eomsangdonandClaude Opus 5.5 8e96880880 feat(CAD): 331-1 각도 고정 <각도 → 길이 · 시퀀스 첫 점 숫자 무시
- 새 input-angle-lock — 다음 한 점만 고정 방향(동쪽 0° 반시계) · 커서 · 클릭은 고정 직선 위로 · 숫자는 그 방향 길이 · 한 점 · Esc · 새 명령이면 풂 · 커서 옆 안내 「각도 30° 고정 — 길이 입력」
- input-patterns — 숫자 음수 · .5 허용 · 각도 고정 꼴 · 점 글 해석 한 곳(parsePointText)
- Enter 해석 차례 — 자유 글 단계만 원문 먼저 → 각도 고정 → 점 · 거리 → 단계 선택지 → 명령 이름
- 선 · 시퀀스 미리보기도 고정 방향 · 시퀀스 첫 점의 거리 · 상대 좌표는 무시(도구 멈춤 · 「Error in tool actor」 없앰)
- 시험 resources/tester/cad/keyboard-chain.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:33:28 +09:00

560 lines
20 KiB
TypeScript

import { Point } from '@flatten-js/core';
import { compact, round } from 'es-toolkit';
import { Actor } from 'xstate';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { MouseButton } from '../App.types.ts';
import {
describeCommand,
matchCommandPrefixes,
resolveCommandInput,
} from '../commands/registry.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';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import { pickRadius } from '../helpers/pick-radius.ts';
import {
getActiveToolActor,
getAngleStep,
getCanvas,
getPickableEntities,
getGridEnabled,
getLastStateInstructions,
getPanStartLocation,
getScreenCanvasDrawController,
getSelectedEntities,
getSnapEnabled,
getSnapPoint,
getSnapPointOnAngleGuide,
isHostSave,
isViewOnly,
redo,
setActiveToolActor,
setAngleStep,
setGhostHelperEntities,
setGridEnabled,
setHighlightedEntityIds,
setPanStartLocation,
setSelectedEntityIds,
setShouldDrawCursor,
setSnapEnabled,
undo,
} from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts';
import {
activeToolAcceptsText,
activeToolWantsFreeText,
ImeSink,
isImeKey,
isPlainSpace,
} from './ime-sink.ts';
import { drawInputField } from './input-field-draw.ts';
import { ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import { attachTouch } from './input-touch.ts';
import { ANGLE_LOCK_REGEXP, parsePointText, type PointTextEvent } from './input-patterns.ts';
import { angleLockHint, setAngleLock } from './input-angle-lock.ts';
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
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);
});
// Listen for right mouse button click => perform the same action as ENTER
const canvas = getCanvas();
canvas?.addEventListener('mousedown', (evt: MouseEvent) => this.handleMouseDown(evt));
canvas?.addEventListener('mousemove', (evt: MouseEvent) => this.handleMouseMove(evt));
canvas?.addEventListener('mouseup', (evt: MouseEvent) => this.handleMouseUp(evt));
// passive:false — 핀치·휠의 브라우저 기본 확대/스크롤을 막아야 한다.
canvas?.addEventListener('wheel', (evt: WheelEvent) => this.handleMouseWheel(evt), {
passive: false,
});
canvas?.addEventListener('mouseout', () => this.handleMouseOut());
canvas?.addEventListener('mouseenter', () => this.handleMouseEnter());
// Stop the context menu from appearing when right-clicking
canvas?.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
});
// 가운데 단추 — 브라우저 자동 스크롤(페이지가 같이 움직임) · 붙여넣기를 막는다.
// 막는 자리는 mousedown(아래 handleMouseDown) — pointerdown 을 막으면 mousedown 이 안 와 팬이 끊긴다.
canvas?.addEventListener('auxclick', (evt: MouseEvent) => {
if (evt.button === MouseButton.Middle) evt.preventDefault();
});
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
document.addEventListener(
'wheel',
(evt: WheelEvent) => {
if (evt.ctrlKey) evt.preventDefault();
},
{ passive: false }
);
attachTouch(this); // 손가락(기기대응 4-1) — 마우스 경로는 그대로
}
public draw(drawController: ScreenCanvasDrawController) {
this.ime.sync(activeToolAcceptsText());
const toolInstruction = getLastStateInstructions();
const texts: string[] = [];
if (toolInstruction) {
// Draw tool instruction
texts.push(toolInstruction);
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
texts.push(`${roundedX},${roundedY}`);
}
const lockHint = angleLockHint();
if (lockHint) texts.push(lockHint);
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
texts.push(...this.getCommandSuggestions());
// 입력 글(조합 중인 한글도 함께)
drawInputField(drawController, this.text + this.ime.composing, texts);
}
public submitText(value: string) {
// 명령 해석은 대소문자를 가리지 않으므로 원문을 그대로 둔다 (문자 주석의 대소문자 보존)
this.text = value.trim();
this.handleEnterKey();
}
public handleMouseUp(evt: MouseEvent) {
if (evt.button === MouseButton.Right) {
// Right click => confirm action (ENTER)
evt.preventDefault();
evt.stopPropagation();
this.handleEnterKey();
}
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
const controlsParent = (evt?.target as HTMLElement)?.closest('.controls');
if (controlsParent) {
return;
}
if (evt.button === MouseButton.Middle) {
setPanStartLocation(null);
}
if (evt.button === MouseButton.Left && isViewOnly()) {
setPanStartLocation(null);
return;
}
if (evt.button === MouseButton.Left) {
if (this.isSelectToolActive()) {
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
const pressLocation = this.leftPressLocation;
this.leftPressLocation = null;
const releaseLocation = this.getCanvasPoint(evt);
const dragged =
!!pressLocation &&
Math.hypot(releaseLocation.x - pressLocation.x, releaseLocation.y - pressLocation.y) >
DRAG_SELECT_MIN_PX;
if (dragged && this.isWaitingForSecondSelectPoint()) {
this.sendMouseClick(evt);
}
return;
}
this.sendMouseClick(evt);
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
}
}
/** 활성 도구가 선택 도구인가 */
private isSelectToolActive(): boolean {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
}
/** 선택 도구가 선택 사각형의 두 번째 점을 기다리는 중인가 */
private isWaitingForSecondSelectPoint(): boolean {
return (
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT
);
}
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 */
private sendMouseClick(evt: MouseEvent) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
this.sendPointEvent({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
} as MouseClickEvent);
}
public handleMouseEnter() {
setShouldDrawCursor(true);
}
public handleMouseMove(evt: MouseEvent) {
setShouldDrawCursor(true);
const screenCanvasDrawController = getScreenCanvasDrawController();
const newScreenMouseLocation = this.getCanvasPoint(evt);
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
// If the middle mouse button is pressed, pan the screen
const panStartLocation = getPanStartLocation();
if (panStartLocation) {
screenCanvasDrawController.panScreen(
newScreenMouseLocation.x - panStartLocation.x,
newScreenMouseLocation.y - panStartLocation.y
);
setPanStartLocation(newScreenMouseLocation);
}
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
if (isViewOnly()) return;
// Calculate angle guides and snap points
if (getSnapEnabled()) {
calculateAngleGuidesAndSnapPoints();
}
// Highlight the entity closest to the mouse when the select tool is active
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
const closestEntityInfo = findClosestEntity(
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
getPickableEntities()
);
if (closestEntityInfo.distance < pickRadius()) {
setHighlightedEntityIds([closestEntityInfo.entity.id]);
} else {
setHighlightedEntityIds([]);
}
}
}
public handleMouseOut() {
setShouldDrawCursor(false);
}
/**
* Change the zoom level of screen space
* @param evt
*/
public handleMouseWheel(evt: WheelEvent) {
// 확대는 도면만 — 트랙패드 핀치(ctrl+휠)가 브라우저 전체를 키우던 것을 막는다.
evt.preventDefault();
const zoom = wheelZoom(evt);
if (zoom) getScreenCanvasDrawController().zoomScreen(zoom.delta, zoom.exponent);
}
public handleMouseDown(evt: MouseEvent) {
if (evt.button === MouseButton.Middle) {
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
if (evt.button !== MouseButton.Left) return;
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
if (isViewOnly()) {
// 미리보기 — 왼쪽 끌기는 선택 상자 대신 화면 옮기기.
evt.preventDefault();
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!this.isSelectToolActive()) return;
this.leftPressLocation = this.getCanvasPoint(evt);
this.sendMouseClick(evt);
}
private getCanvasPoint(evt: MouseEvent): Point {
const bounds = getCanvas()?.getBoundingClientRect();
return new Point(
evt.clientX - (bounds?.left ?? 0),
(bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY
);
}
/**
* Returns a distance to pan the screen when a directional arrow is pressed
* Offset is based on shift key being pressed (larger offset)
* and zoom level
* @private
*/
private getScreenPanStep(
direction: 'up' | 'right' | 'down' | 'left',
shiftPressed: boolean
): Point {
const screenOffset = getScreenCanvasDrawController().getScreenOffset();
const screenZoom = getScreenCanvasDrawController().getScreenScale();
let step = 20;
if (shiftPressed) {
step = 100;
}
step *= screenZoom;
switch (direction) {
case 'up':
return new Point(screenOffset.x, screenOffset.y - step);
case 'right':
return new Point(screenOffset.x - step, screenOffset.y);
case 'down':
return new Point(screenOffset.x, screenOffset.y + step);
case 'left':
return new Point(screenOffset.x + step, screenOffset.y);
}
}
public handleKeyStroke(evt: KeyboardEvent) {
const target = evt.target as HTMLElement | null;
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
return;
}
const shortcut = ctrlShortcut(evt);
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
if (isImeKey(evt) && !shortcut) return;
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
return;
}
if (evt.key === 'F5') {
// F5 => reload the page
return;
}
if (evt.key === 'F11') {
// F11 => toggle fullscreen
// ponytail: AutoCAD는 F11이 객체 스냅 추적이지만 브라우저 전체화면이 우선이다.
// 스냅 추적은 상태막대 버튼으로 켜고 끈다.
return;
}
// 제도 보조 토글 (AutoCAD 상태막대 기능키)
if (evt.key === 'F3') {
evt.preventDefault();
setSnapEnabled(!getSnapEnabled());
return;
}
if (evt.key === 'F7') {
evt.preventDefault();
setGridEnabled(!getGridEnabled());
return;
}
if (evt.key === 'F8') {
evt.preventDefault();
setAngleStep(getAngleStep() === 90 ? 0 : 90);
return;
}
if (evt.key === 'F10') {
evt.preventDefault();
setAngleStep(getAngleStep() === 45 ? 0 : 45);
return;
}
if (evt.key === 'Tab') {
// Tab => move keyboard focus
return;
}
if (evt.ctrlKey && evt.code === 'KeyV') {
// 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로)
return;
}
evt.preventDefault();
evt.stopPropagation();
if (shortcut === 'undo') {
// User wants to undo the last action
this.handleUndo(evt);
} else if (shortcut === 'redo') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (shortcut === 'save') {
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
if (isHostSave()) requestHostSave();
} else if (shortcut === 'selectAll') {
// User wants to select everything
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
} else if (evt.key === 'Backspace') {
// Remove the last character from the input field
evt.preventDefault();
this.text = this.text.slice(0, this.text.length - 1);
} else if (evt.key === 'Delete') {
// User wants to delete the current selection
evt.preventDefault();
getActiveToolActor()?.send({
type: ActorEvent.DELETE,
});
} else if (evt.key === 'Escape') {
// User wants to cancel the current action
this.handleEscapeKey();
} else if (evt.key === 'Enter') {
// User wants to submit the input or submit the action
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));
} else if (evt.key === 'ArrowUp') {
// Move the screen up
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('up', evt.shiftKey));
} else if (evt.key === 'ArrowLeft') {
// Move the screen left
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('left', evt.shiftKey));
} else if (evt.key === 'ArrowRight') {
// Move the screen right
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey));
} else if (evt.key?.length === 1) {
// +·-는 확대·축소 단축키로 쓰지 않는다 — 음수 좌표(-100,-50)의 첫 글자를
// 먹어 버렸다. 줌은 휠·뷰 막대·ZOOMIN/ZOOMOUT 명령으로 한다.
// User entered a single character => add to input field text
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다.
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
}
}
public handleEscapeKey() {
setAngleLock(null);
if (getSelectedEntities().length > 0) {
// Deselect entities
setSelectedEntityIds([]);
} else if (this.text === '') {
// Cancel tool action
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
} else {
// clear the input field
this.text = '';
}
}
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) */
private getCommandSuggestions(): string[] {
if (this.text === '') {
return [];
}
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
}
/** @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();
if (this.text === '') {
console.log('ENTER: ', {
text: this.text,
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,
});
// 선택 도구는 ENTER(우클릭)로 최종 상태에 들어가 **멈춘다**. 다른 도구가 선택을
// 자식으로 불러 쓸 때는 그 끝남이 필요하지만(move·copy·rotate의 onDone),
// 선택이 그 자체로 활성 도구일 때 멈추면 커서 옆 안내가 사라지고 이후 클릭도
// 안 먹는다(2026-08-30 사용자 지적). 그 경우에만 새로 세워 준다.
if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') {
setActiveToolActor(new Actor(selectToolStateMachine));
}
} else if (activeToolWantsFreeText()) {
// 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글
this.sendText();
} else if (ANGLE_LOCK_REGEXP.test(this.text)) {
// 각도 고정 `<30` — 다음 한 점만 그 방향
setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1]));
this.text = '';
} else if (this.pointEventFromText()) {
// 거리 `100` · `x,y` · `@x,y` · `@d<a` · `d<a`
this.sendPointEvent(this.pointEventFromText() as PointTextEvent);
this.text = '';
} else if (activeToolCanHandleTextInput) {
// 지금 단계의 선택지(배열 L/R · 숫자 단계 글)
this.sendText();
} else if (resolveCommandInput(this.text)) {
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
const command = resolveCommandInput(this.text);
if (command) {
runCommand(command);
}
this.text = '';
} else {
this.sendText();
}
}
private sendText() {
console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor() });
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
this.text = '';
}
/** 친 글을 점 · 거리 사건으로 — 거리의 방향 = 스냅 · 가이드 · 커서 */
private pointEventFromText(): PointTextEvent | null {
return parsePointText(
this.text,
() =>
getSnapPointOnAngleGuide()?.point ||
getSnapPoint()?.point ||
getScreenCanvasDrawController().getWorldMouseLocation()
);
}
/** 점 하나를 도구에 보냄 — 각도 고정은 이 한 점으로 끝 */
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
getActiveToolActor()?.send(event);
setAngleLock(null);
}
public handleUndo(evt: KeyboardEvent) {
evt.preventDefault();
undo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
}
public handleRedo(evt: KeyboardEvent) {
evt.preventDefault();
redo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
}
}