import { Point } from '@flatten-js/core'; import { compact, round } from 'es-toolkit'; import { Actor } from 'xstate'; import { CANVAS_INPUT_FIELD_BACKGROUND_COLOR, CANVAS_INPUT_FIELD_FONT_SIZE, CANVAS_INPUT_FIELD_HEIGHT, CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, CANVAS_INPUT_FIELD_MOUSE_OFFSET, CANVAS_INPUT_FIELD_TEXT_COLOR, CANVAS_INPUT_FIELD_WIDTH, PINCH_ZOOM_EXPONENT, SNAP_POINT_DISTANCE, WHEEL_LINE_PX, WHEEL_PAGE_PX, WHEEL_ZOOM_EXPONENT, } from '../App.consts.ts'; import { MouseButton } from '../App.types.ts'; import { describeCommand, matchCommandPrefixes, resolveCommandInput, } from '../commands/registry.ts'; import { runCommand } from '../commands/run-command.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, 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 { type AbsolutePointInputEvent, ActorEvent, type MouseClickEvent, type NumberInputEvent, type RelativePointInputEvent, type TextInputEvent, } from '../tools/tool.types.ts'; const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; /** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); /** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); /** 거리·각도(도)를 x·y 변위로 바꾼다 */ function polarToPoint(distance: number, degrees: number): Point { const radians = (degrees * Math.PI) / 180; return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); } /** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */ const DRAG_SELECT_MIN_PX = 4; export class InputController { private text = ''; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; constructor() { if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { return; // used during unit testing } // 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(); }); // 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다. // ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다. document.addEventListener( 'wheel', (evt: WheelEvent) => { if (evt.ctrlKey) evt.preventDefault(); }, { passive: false } ); } public draw(drawController: ScreenCanvasDrawController) { const screenMouseLocation = drawController.getScreenMouseLocation(); // draw input field drawController.fillRectScreen( screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET, // fillRectScreen이 아래 변 기준으로 바로잡혔으므로, 칸이 있던 자리를 지키려고 // 높이만큼 내려 잡는다. screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT, CANVAS_INPUT_FIELD_WIDTH, CANVAS_INPUT_FIELD_HEIGHT, CANVAS_INPUT_FIELD_BACKGROUND_COLOR ); // Draw text in input field if (this.text) { drawController.drawTextScreen( this.text, new Point( screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2 ), { textAlign: 'left', textColor: CANVAS_INPUT_FIELD_TEXT_COLOR, fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, } ); } const matchingCommands = this.getCommandSuggestions(); 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}`); } if (matchingCommands.length) { // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) texts.push(...matchingCommands); } this.drawListBelowInputField(drawController, 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) { 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); } } /** 활성 도구가 선택 도구인가 */ 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; getActiveToolActor()?.send({ 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); } // 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 drawController = getScreenCanvasDrawController(); // deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다. const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1; const deltaX = evt.deltaX * unitPx; const deltaY = evt.deltaY * unitPx; // 핀치(ctrl+휠)는 기기와 무관하게 확대. if (evt.ctrlKey) { if (deltaY !== 0) drawController.zoomScreen(-deltaY, PINCH_ZOOM_EXPONENT); return; } // 트랙패드 두 손가락 이동은 팬 — 문서를 스크롤하듯 화면이 손가락 반대로 간다. if (!isMouseWheel(evt)) { drawController.panScreen(-deltaX, deltaY); return; } if (deltaY === 0) { return; // We can't zoom by zero delta } // Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing // zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02). drawController.zoomScreen(-deltaY, WHEEL_ZOOM_EXPONENT); } public handleMouseDown(evt: MouseEvent) { if (evt.button === MouseButton.Middle) { setPanStartLocation(this.getCanvasPoint(evt)); return; } if (evt.button !== MouseButton.Left) return; if ((evt.target as HTMLElement | null)?.closest('.controls')) 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) { if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) { 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; } evt.preventDefault(); evt.stopPropagation(); if (evt.ctrlKey && evt.key === 'v') { // User wants to paste the clipboard } else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') { // User wants to undo the last action this.handleUndo(evt); } else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') { // User wants to redo the last action this.handleRedo(evt); } else if (evt.ctrlKey && evt.key === 'y') { // User wants to redo the last action this.handleRedo(evt); } else if (evt.ctrlKey && evt.key === 'a') { // 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(); } 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 this.text += evt.key.toUpperCase(); } } public handleEscapeKey() { 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); } public handleEnterKey() { // submit the text as input to the active tool and clear the input field const activeTool = getActiveToolActor(); const activeToolSnapshot = activeTool?.getSnapshot(); const activeToolState = activeToolSnapshot?.value; const activeToolCanHandleTextInput = !!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT; if (this.text === '') { console.log('ENTER: ', { text: this.text, activeTool: getActiveToolActor(), }); const wasSelectTool = this.isSelectToolActive(); // 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 (activeToolCanHandleTextInput) { console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor(), }); // Send the text to the active tool getActiveToolActor()?.send({ type: ActorEvent.TEXT_INPUT, value: this.text, } as TextInputEvent); this.text = ''; } else if (resolveCommandInput(this.text)) { // 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC const command = resolveCommandInput(this.text); if (command) { runCommand(command); } this.text = ''; } else if (NUMBER_REGEXP.test(this.text)) { console.log(' NUMBER_INPUT: ', { text: this.text, activeTool: getActiveToolActor(), }); // User entered a number. eg: 100 getActiveToolActor()?.send({ type: ActorEvent.NUMBER_INPUT, value: Number.parseFloat(this.text), worldMouseLocation: getSnapPointOnAngleGuide()?.point || getSnapPoint()?.point || getScreenCanvasDrawController().getWorldMouseLocation(), } as NumberInputEvent); this.text = ''; } else if (ABSOLUTE_POINT_REGEXP.test(this.text)) { console.log('ABSOLUTE_POINT_INPUT: ', { text: this.text, activeTool: getActiveToolActor(), }); // User entered coordinates to an absolute point on the canvas. eg: 100, 200 const match = ABSOLUTE_POINT_REGEXP.exec(this.text); if (!match) { return; } const x = Number.parseFloat(match[1]); const y = Number.parseFloat(match[2]); getActiveToolActor()?.send({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: new Point(x, y), } as AbsolutePointInputEvent); this.text = ''; } else if (RELATIVE_POINT_REGEXP.test(this.text)) { console.log('RELATIVE_POINT_INPUT: ', { text: this.text, activeTool: getActiveToolActor(), }); // User entered coordinates to a relative point on the canvas. eg: @100, 200 const match = RELATIVE_POINT_REGEXP.exec(this.text); if (!match) { return; } const x = Number.parseFloat(match[1]); const y = Number.parseFloat(match[2]); getActiveToolActor()?.send({ type: ActorEvent.RELATIVE_POINT_INPUT, value: new Point(x, y), } as RelativePointInputEvent); this.text = ''; } else if (RELATIVE_POLAR_REGEXP.test(this.text)) { // 직전 점에서 거리·각도로 이동. 예: @100<45 const match = RELATIVE_POLAR_REGEXP.exec(this.text); if (!match) { return; } getActiveToolActor()?.send({ type: ActorEvent.RELATIVE_POINT_INPUT, value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])), } as RelativePointInputEvent); this.text = ''; } else if (ABSOLUTE_POLAR_REGEXP.test(this.text)) { // 원점에서 거리·각도로 지정한 점. 예: 100<45 const match = ABSOLUTE_POLAR_REGEXP.exec(this.text); if (!match) { return; } getActiveToolActor()?.send({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])), } as AbsolutePointInputEvent); this.text = ''; } else { console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor(), }); // Send the text to the active tool getActiveToolActor()?.send({ type: ActorEvent.TEXT_INPUT, value: this.text, } as TextInputEvent); this.text = ''; } } 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, }); } private drawListBelowInputField( drawController: ScreenCanvasDrawController, texts: string[] ): void { const screenMouseLocation = drawController.worldToTarget( drawController.getWorldMouseLocation() ); const startY = screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2; const offsetY = CANVAS_INPUT_FIELD_HEIGHT; texts.forEach((text, index) => { drawController.drawTextScreen( text, new Point( screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, startY - index * offsetY ), { textAlign: 'left', textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, } ); }); } } /** * 마우스 휠인지 트랙패드인지 가른다. 마우스 휠은 한 칸이 정해진 크기(Chrome 100px, * 일부 브라우저 120px)로 딱 떨어지고 가로 델타가 없다. 트랙패드는 손가락이 움직인 * 만큼 잔 델타를 보내고 가로 델타도 함께 온다. 줄·쪽 단위(deltaMode≠0)는 휠뿐이다. */ function isMouseWheel(evt: WheelEvent): boolean { if (evt.deltaMode !== 0) return true; if (evt.deltaX !== 0) return false; const step = Math.abs(evt.deltaY); return step !== 0 && (step % 100 === 0 || step % 120 === 0); }